Python Avanzado Operador de Punto

Python Avanzado El Operador de Punto Explorado

El operador que habilita el paradigma orientado a objetos en Python

El operador punto es uno de los pilares del paradigma orientado a objetos en Python. Foto de Madeline Pere en Unsplash

Esta vez, escribiré sobre algo aparentemente trivial. Es el “operador punto”. La mayoría de ustedes ya han utilizado este operador muchas veces, sin saber o cuestionar qué sucede detrás de escena. Y en comparación con el concepto de metaclasses del que hablé la última vez, este es un poco más útil para tareas diarias. Es broma, prácticamente lo usan cada vez que usan Python para algo más que un “Hola Mundo”. Esta es precisamente la razón por la que pensé que podría interesarles profundizar, y quiero ser su guía. ¡Comencemos el viaje!

Comenzaré con una pregunta trivial: “¿Qué es un operador punto?”

Aquí hay un ejemplo:

hello = '¡Hola mundo!'print(hello.upper())# ¡HOLA MUNDO!

Bueno, este es sin duda un ejemplo de “Hola Mundo”, pero difícilmente puedo imaginar que alguien comience a enseñarte Python exactamente de esta manera. De todos modos, el “operador punto” es la parte “.” de hello.upper(). Intentemos dar un ejemplo más detallado:

class Persona:    num_de_personas = 0    def __init__(self, nombre):        self.nombre = nombre    def gritar(self):        print(f"¡Hey! Soy {self.nombre}")p = Persona('John')p.gritar()# ¡Hey, soy John.p.num_de_personas# 0p.nombre# 'John'

Hay algunos lugares donde usas el “operador punto”. Para facilitar la visualización del panorama general, resumamos la forma en que lo usas en dos casos:

  • úsalo para acceder a atributos de un objeto o clase,
  • úsalo para acceder a funciones definidas en la definición de la clase.

Obviamente, tenemos todo esto en nuestro ejemplo, y esto parece intuitivo y como se esperaba. ¡Pero hay más de lo que se ve a simple vista! Presta atención a este ejemplo:

p.gritar# <bound method Persona.gritar de <__main__.Persona object at 0x1037d3a60>>id(p.gritar)# 4363645248Persona.gritar# <function __main__.Persona.gritar(self)>id(Persona.gritar)# 4364388816

De alguna manera, p.gritar no está haciendo referencia a la misma función que Persona.gritar aunque debería. Al menos lo esperarías, ¿verdad? Y ¡p.gritar ni siquiera es una función! Veamos el siguiente ejemplo antes de comenzar a discutir qué está sucediendo:

class Persona:    num_de_personas = 0    def __init__(self, nombre):        self.nombre = nombre    def gritar(self):        print(f"¡Hey! Soy {self.nombre}.")p = Persona('John')vars(p)# {'nombre': 'John'}def gritar_v2(self):    print("¡Hey, ¿qué tal?")p.gritar_v2 = gritar_v2vars(p)# {'nombre': 'John', 'gritar_v2': <function __main__.shout_v2(self)>}p.gritar()# ¡Hey, soy John.p.gritar_v2()# TypeError: saludo_v2() falta 1 argumento posicional requerido: 'self'

Para aquellos que no conocen la función vars, esta devuelve el diccionario que contiene los atributos de una instancia. Si ejecutas vars(Persona) obtendrás una respuesta un poco diferente, pero entenderás la idea. Habrá tanto atributos con sus valores como variables que contienen definiciones de funciones de la clase. Obviamente, hay una diferencia entre un objeto que es una instancia de una clase y el objeto de la clase en sí, por lo tanto, habrá una diferencia en la respuesta de la función vars para estos dos casos.

Ahora, es perfectamente válido definir una función adicional después de crear un objeto. Esta es la línea p.shout_v2 = shout_v2. Esto introduce otro par clave-valor en el diccionario de la instancia. Aparentemente todo está bien y podremos seguir adelante sin problemas, como si shout_v2 fuera especificado en la definición de la clase. ¡Pero ay! Algo está realmente mal. No podemos llamarlo de la misma manera que lo hicimos con el método shout.

Los lectores perspicaces deberían haber notado ahora con qué cuidado uso los términos función y método. Después de todo, hay una diferencia en cómo Python los imprime. Echa un vistazo a los ejemplos anteriores. shout es un método, shout_v2 es una función. Al menos si los miramos desde la perspectiva del objeto p. Si los miramos desde la perspectiva de la clase Person, shout es una función y shout_v2 no existe. Está definido solo en el diccionario (espacio de nombres) del objeto. Así que si realmente vas a confiar en los paradigmas y mecanismos orientados a objetos como la encapsulación, la herencia, la abstracción y la polimorfismo, no definirás funciones en objetos, como en nuestro ejemplo con p. Te asegurarás de que estás definiendo funciones en la definición (cuerpo) de una clase.

Entonces, ¿por qué son diferentes y por qué obtenemos el error? Bueno, la respuesta más rápida es por cómo funciona el “operador de punto”. La respuesta más larga es que hay un mecanismo detrás de escena que hace la resolución (de nombres) de atributos por ti. Este mecanismo consiste en los métodos mágicos __getattribute__ y __getattr__.

Obteniendo los atributos

Al principio, esto probablemente sonará poco intuitivo y algo innecesariamente complicado, pero ten paciencia. Básicamente, hay dos escenarios que pueden ocurrir cuando intentas acceder a un atributo de un objeto en Python: o bien hay un atributo o no lo hay. Simple. En ambos casos, se llama a __getattribute__, o para simplificarlo, se llama siempre. Este método:

  • devuelve el valor del atributo calculado,
  • llama explícitamente a __getattr__, o
  • lanza una excepción AttributeError en cuyo caso se llama a __getattr__ por defecto.

Si quieres interceptar el mecanismo que resuelve los nombres de atributos, este es el lugar para aprovecharlo. Solo tienes que tener cuidado, porque es muy fácil caer en un bucle infinito o arruinar todo el mecanismo de resolución de nombres, especialmente en el escenario de la herencia orientada a objetos. No es tan simple como parece.

Si quieres manejar casos en los que no hay un atributo en el diccionario del objeto, puedes implementar directamente el método __getattr__. Este se llama cuando __getattribute__ no puede acceder al nombre del atributo. Si este método no puede encontrar un atributo o manejar uno que falta, también lanza una excepción AttributeError. Aquí tienes cómo jugar con estos:

class Person:
    num_of_persons = 0
    def __init__(self, name):
        self.name = name
    def shout(self):
        print(f"¡Oye! Soy {self.name}.")
        
    def __getattribute__(self, name):
        print(f'Obteniendo el nombre del atributo: {name}')
        return super().__getattribute__(name)
        
    def __getattr__(self, name):
        print(f'este atributo no existe: {name}')
        raise AttributeError()

p = Person('John')
p.name  # Obteniendo el nombre del atributo: name # 'John'
p.name1  # Obteniendo el nombre del atributo: name1 # este atributo no existe: name1
# ... Rastreo de pila de excepciones
# AttributeError:

Es muy importante llamar a super().__getattribute__(...) en tu implementación de __getattribute__, y la razón, como escribí antes, es que hay mucho sucediendo en la implementación predeterminada de Python. Y este es exactamente el lugar donde el “operador de punto” obtiene su magia. Bueno, al menos la mitad de la magia está allí. La otra parte está en cómo se crea un objeto de clase después de interpretar la definición de la clase.

Funciones de clase

El término que uso aquí es intencional. La clase solo contiene funciones, y vimos esto en uno de los primeros ejemplos:

p.shout# <método enlazado Person.shout del objeto Person en 0x1037d3a60>>Person.shout# <función __main__.Person.shout(self)>

Cuando se ve desde la perspectiva del objeto, se llaman métodos. El proceso de transformar la función de una clase en un método de un objeto se llama enlace, y el resultado es lo que se ve en el ejemplo anterior, un método enlazado. ¿Qué lo hace enlazado, y a qué? Bueno, una vez que tienes una instancia de una clase y comienzas a llamar a sus métodos, en esencia, estás pasando la referencia del objeto a cada uno de sus métodos. ¿Recuerdas el argumento self? Entonces, ¿cómo sucede esto y quién lo hace?

Bueno, la primera parte sucede cuando se está interpretando el cuerpo de la clase. Hay varias cosas que suceden en este proceso, como definir un espacio de nombres de clase, agregar valores de atributos, definir funciones (de clase) y enlazarlas a sus nombres. Ahora, a medida que se definen estas funciones, se envuelven de alguna manera. Envueltas en un objeto conceptualmente llamado descriptor. Este descriptor permite este cambio en la identificación y el comportamiento de las funciones de clase que vimos anteriormente. Me aseguraré de escribir una publicación de blog separada sobre descriptores, pero por ahora, sepan que este objeto es una instancia de una clase que implementa un conjunto predefinido de métodos especiales. Esto también se llama un Protocolo. Una vez que se implementan estos, se dice que los objetos de esta clase siguen el protocolo específico y, por lo tanto, se comportan de la manera esperada. Hay una diferencia entre los descriptores de datos y no de datos. Los primeros implementan los métodos especiales __get__, __set__ y/o __delete__. Los últimos, solo implementan el método __get__. De todos modos, cada función en una clase termina envuelta en un descriptor llamado no de datos.

Una vez que inicias la búsqueda de atributos usando el operador de punto, se llama al método __getattribute__ y comienza todo el proceso de resolución de nombres. Este proceso se detiene cuando la resolución tiene éxito y funciona así:

  1. devuelve el descriptor de datos que tiene el nombre deseado (nivel de clase), o
  2. devuelve el atributo de instancia con el nombre deseado (nivel de instancia), o
  3. devuelve el descriptor que no es de datos con el nombre deseado (nivel de clase), o
  4. devuelve el atributo de clase con el nombre deseado (nivel de clase), o
  5. genera un AttributeError que básicamente llama al método __getattr__.

Mi idea inicial era dejarte con una referencia a la documentación oficial sobre cómo se implementa este mecanismo, al menos un esquema de Python, con fines educativos, pero decidí ayudarte también con esa parte. Sin embargo, te recomiendo encarecidamente que vayas y leas toda la página de documentación oficial.

Entonces, en el siguiente fragmento de código, colocaré algunas de las descripciones en los comentarios, para que sea más fácil de leer y entender el código. Aquí está:

def object_getattribute(obj, name):    "Emula PyObject_GenericGetAttr() en Objects/object.c"    # Crea un objeto normal para su uso posterior.    null = object()    """    obj es un objeto instanciado de nuestra clase personalizada. Aquí intentamos     encontrar el nombre de la clase a partir de la cual se instanció.    """    objtype = type(obj)     """    name representa el nombre de la función de clase, atributo de instancia     o cualquier atributo de clase. Aquí intentamos encontrarlo y mantener una     referencia a él. MRO es la sigla en inglés de Method Resolution Order, y     tiene que ver con la herencia de clases. No es realmente importante en     este punto. Digamos que este mecanismo encuentra de manera óptima el nombre     a través de todas las clases padre.    """    cls_var = find_name_in_mro(objtype, name, null)    """    Aquí comprobamos si este atributo de clase es un objeto que tiene     implementado el método __get__. Si lo hace, es un descriptor que no     es de datos. Esto es importante para los pasos posteriores.    """    descr_get = getattr(type(cls_var), '__get__', null)    """    Entonces, ahora bien, o nuestro atributo de clase hace referencia a un descriptor,     en cuyo caso probamos si es un descriptor de datos y devolvemos una referencia     al método __get__ del descriptor, o pasamos al siguiente bloque de código if.    """    if descr_get is not null:        if (hasattr(type(cls_var), '__set__')            or hasattr(type(cls_var), '__delete__')):            return descr_get(cls_var, obj, objtype)  # descriptor de datos    """    En los casos en los que el nombre no hace referencia a un descriptor de datos,     verificamos si hace referencia a la variable en el diccionario del objeto,     y si es así, devolvemos su valor.    """    if hasattr(obj, '__dict__') and name in vars(obj):        return vars(obj)[name]  # variable de instancia    """    En los casos en los que el nombre no hace referencia a la variable en el     diccionario del objeto, intentamos ver si hace referencia a un descriptor     que no es de datos y devolvemos una referencia a él.    """    if descr_get is not null:        return descr_get(cls_var, obj, objtype)  # descriptor que no es de datos    """    En caso de que el nombre no haya hecho referencia a nada de lo anterior,     intentamos ver si hace referencia a un atributo de clase y devolvemos su valor.    """    if cls_var is not null:        return cls_var                                  # variable de clase    """    Si la resolución del nombre no tuvo éxito, generamos una excepción AttriuteError,     y se invoca a __getattr__.    """    raise AttributeError(name)

Ten en cuenta que esta implementación es en Python con el fin de documentar y describir la lógica implementada en el método __getattribute__. En realidad, está implementado en C. Solo con mirarlo, puedes imaginar que es mejor no jugar con volver a implementar todo el asunto. La mejor manera es intentar hacer parte de la resolución por ti mismo y luego recurrir a la implementación de CPython con return super().__getattribute__(name) como se muestra en el ejemplo anterior.

Lo importante aquí es que cada función de clase (que es un objeto) se envuelve en un descriptor no de datos (que es un objeto de clase function), y esto significa que este objeto envoltorio tiene el método dunder __get__ definido. Lo que hace este método dunder es devolver un nuevo objeto invocable (piénsalo como una nueva función), donde el primer argumento es la referencia al objeto en el que estamos realizando el “operador de punto”. Dije que lo pienses como una nueva función porque es invocable. En esencia, es otro objeto llamado MethodType. Échale un vistazo:

type(p.shout)# obteniendo el nombre del atributo: shout# methodtype(Person.shout)# función

Una cosa interesante sin duda es esta clase function. Esta es exactamente el objeto envoltorio que define el método __get__. Sin embargo, una vez que intentamos accederlo como el método shout con el “operador de punto”, __getattribute__ itera a través de la lista y se detiene en el tercer caso (retorna un descriptor no de datos). Este método __get__ contiene lógica adicional que toma la referencia del objeto y crea MethodType con referencia a la function y al objeto.

Aquí está el esquema de la documentación oficial:

class Function:    ...    def __get__(self, obj, objtype=None):        if obj is None:            return self        return MethodType(self, obj)

Ignora la diferencia en el nombre de la clase. He estado usando function en lugar de Function para que sea más fácil de entender, pero usaré el nombre Function a partir de ahora para seguir la explicación de la documentación oficial.

De todos modos, solo con mirar este esquema, puede ser suficiente para entender cómo encaja esta clase function en la imagen, pero déjame agregar un par de líneas de código que faltan, lo que probablemente hará las cosas aún más claras. Agregaré dos funciones de clase más en este ejemplo, a saber:

class Function:    ...    def __init__(self, fun, *args, **kwargs):        ...        self.fun = fun    def __get__(self, obj, objtype=None):        if obj is None:            return self        return MethodType(self, obj)    def __call__(self, *args, **kwargs):        ...        return self.fun(*args, **kwargs)

¿Por qué agregué estas funciones? Bueno, ahora puedes imaginar fácilmente cómo el objeto Function juega su papel en todo este escenario de enlace de métodos. Este nuevo objeto Function almacena la función original como un atributo. Este objeto también es invocable, lo que significa que podemos invocarlo como una función. En ese caso, funciona igual que la función que envuelve. Recuerda, todo en Python es un objeto, incluso las funciones. Y MethodType ‘envuelve’ el objeto Function junto con la referencia al objeto en el que estamos llamando al método (en nuestro caso, shout).

¿Cómo hace esto MethodType? Bueno, mantiene estas referencias e implementa un protocolo invocable. Aquí está el esquema de la documentación oficial para la clase MethodType:

class MethodType:    def __init__(self, func, obj):        self.__func__ = func        self.__self__ = obj    def __call__(self, *args, **kwargs):        func = self.__func__        obj = self.__self__        return func(obj, *args, **kwargs)

Nuevamente, por simplicidad, func termina referenciando nuestra función de clase inicial (shout), obj hace referencia a la instancia (p), y luego tenemos argumentos y argumentos de palabras clave que se pasan junto. self en la declaración de shout termina referenciando a este ‘obj’, que es esencialmente p en nuestro ejemplo.

Al final, debe quedar claro por qué hacemos una distinción entre funciones y métodos y cómo las funciones se vinculan una vez que se acceden a través de objetos usando el “operador de punto”. Si lo piensas bien, estaríamos perfectamente bien invocando funciones de clase de la siguiente manera:

class Persona:    num_de_personas = 0    def __init__(self, nombre):        self.nombre = nombre    def gritar(self):        print(f"¡Hey! Soy {self.nombre}.")        p = Persona('John')Persona.gritar(p)# ¡Hey! Soy John.

Sin embargo, esta realmente no es la forma recomendada y es simplemente fea. Por lo general, no tendrás que hacer esto en tu código.

Entonces, antes de concluir, quiero repasar un par de ejemplos de resolución de atributos para que sea más fácil de entender. Usemos el ejemplo anterior y veamos cómo funciona el operador de punto.

p.nombre"""1. Se invoca __getattribute__ con p y "nombre" como argumentos.2. objtype es Persona.3. descr_get es nulo porque la clase Persona no tiene "nombre" en su diccionario (espacio de nombres).4. Dado que no hay descr_get en absoluto, omitimos el primer bloque if.5. "nombre" existe en el diccionario del objeto, por lo que obtenemos el valor."""p.gritar('¡Hey!')"""Antes de entrar en los pasos de resolución de nombres, ten en cuenta que Persona.gritar es una instancia de una clase de función. Básicamente, está envuelta en ella. Y este objeto es llamable, por lo que puedes invocarlo con Persona.gritar(...). Desde la perspectiva de un desarrollador, todo funciona como si estuviera definido en el cuerpo de la clase. Pero en realidad, no es así.1. Se invoca __getattribute__ con p y "gritar" como argumentos.2. objtype es Persona.3. Persona.gritar está envuelta y es un descriptor que no es de datos.Entonces, este envoltorio realmente tiene el método __get__ implementado, y es referenciado por descr_get.4. El objeto envoltorio es un descriptor que no es de datos, por lo que se omite el primer bloque if.5. "gritar" no existe en el diccionario del objeto porque es parte de la definición de la clase. Se omite el segundo bloque if.6. "gritar" es un descriptor que no es de datos y su método __get__ se devuelve desde el tercer bloque de código if.Ahora bien, intentamos acceder a p.gritar('¡Hey!'), pero lo que obtuvimos fue el método p.gritar.__get__. Esto devuelve un objeto de tipo MethodType. Por eso p.gritar(...) funciona, pero lo que se llama es una instancia de la clase MethodType. Este objeto es básicamente un envoltorio alrededor del envoltorio `Function`, y tiene una referencia al envoltorio `Function` y a nuestro objeto p. Al final, cuando invocas p.gritar('¡Hey!'), lo que realmente se invoca es el envoltorio `Function` con el objeto p y '¡Hey!' como uno de los argumentos posicionales."""Persona.gritar(p)"""Antes de entrar en los pasos de resolución de nombres, ten en cuenta que Persona.gritar es una instancia de una clase de función. Básicamente, está envuelta en ella. Y este objeto es llamable, por lo que puedes invocarlo con Persona.gritar(...). Desde la perspectiva de un desarrollador, todo funciona como si estuviera definido en el cuerpo de la clase. Pero en realidad, no es así.Esta parte es la misma. Los siguientes pasos son diferentes. Míralo.1. Se invoca __getattribute__ con Persona y "gritar" como argumentos.2. objtype es un tipo. Este mecanismo se describe en mi publicación sobre metaclasses.3. Persona.gritar está envuelta y es un descriptor que no es de datos,por lo que este envoltorio realmente tiene el método __get__ implementado, y es referenciado por descr_get.4. El objeto envoltorio es un descriptor que no es de datos, por lo que se omite el primer bloque if.5. "gritar" existe en el diccionario de un objeto porque Persona esen realidad un objeto. Por lo tanto, se devuelve la función "gritar".Cuando se invoca Persona.gritar, lo que realmente se invoca es una instancia de la clase `Function`, que también es llamable y un envoltorio alrededor de la función original definida en el cuerpo de la clase. De esta manera, se llama a la función original con todos los argumentos posicionales y de palabras clave."""

Conclusión

Si leer este artículo de una sola vez no fue una tarea fácil, ¡no te preocupes! El mecanismo completo detrás del “operador de punto” no es algo que se comprenda fácilmente. Hay al menos dos razones, una es cómo __getattribute__ realiza la resolución de nombres y la otra es cómo las funciones de clase se envuelven al interpretar el cuerpo de la clase. Así que asegúrate de repasar el artículo varias veces y jugar con los ejemplos. Experimentar es realmente lo que me impulsó a iniciar una serie llamada Python Avanzado.

¡Una cosa más! Si te gusta la forma en que explico las cosas y hay algo avanzado en el mundo de Python sobre lo que te gustaría leer, ¡grítalo!

Artículos anteriores en la serie Avanzada de Python:

Python Avanzado: Funciones

Después de leer el título, probablemente te preguntes algo como, “Las funciones en Python son avanzadas…

towardsdatascience.com

Python Avanzado: Metaclasses

Una breve introducción al objeto clase de Python y cómo se crea

towardsdatascience.com

Referencias

We will continue to update Zepes; if you have any questions or suggestions, please contact us!

Share:

Was this article helpful?

93 out of 132 found this helpful

Discover more

Inteligencia Artificial

Una introducción práctica a los LLMs

Este es el primer artículo de una serie sobre el uso de Modelos de Lenguaje Grande (LLMs) en la práctica. Aquí daré u...

Ciencia de Datos

Lo que aprendí al llevar la Ingeniería de Prompt al límite

Pasé los últimos dos meses construyendo una aplicación impulsada por un modelo de lenguaje grande (LLM). Fue una expe...

Inteligencia Artificial

La SEC le está dando a las empresas cuatro días para informar ciberataques

Los críticos cuestionan si las nuevas reglas podrían causar más daño que beneficio.

Inteligencia Artificial

Desvelando GPTBot La audaz movida de OpenAI para rastrear la web

En un torbellino de innovación digital, OpenAI ha dado un golpe sorprendente al lanzar GPTBot, un rastreador web dise...