Programación orientada a objetos¶
(1)
La programación orientada a objetos (POO) o en sus siglas inglesas OOP es una manera de programar (paradigma) que permite llevar al código mecanismos similares a los utilizados con entidades de la vida real.
Algunos de sus beneficios son los siguientes:
-
Encapsulamiento
Permite empaquetar el código dentro de una unidad (objeto) donde se puede determinar el ámbito de actuación.
-
Abstracción
Permite generalizar los tipos de objetos a través de las clases y simplificar el programa.
-
Herencia
Permite reutilizar código al poder heredar atributos y comportamientos de una clase a otra.
-
Polimorfismo
Permite crear múltiples objetos a partir de una misma pieza flexible de código.
Objetos¶
Un objeto es una estructura de datos personalizada que contiene datos y código:
- Los datos son variables que reciben el nombre de atributos en POO.
- El código son funciones que reciben el nombre de métodos en POO.
Un objeto representa una instancia única de alguna entidad (a través de los valores de sus atributos) e interactúa con otros objetos (o consigo mismo) a través de sus métodos:
Para crear un objeto primero debemos definir la clase que lo contiene. Podemos pensar en la clase como el molde con el que se crean nuevos objetos de ese tipo:
En el proceso de diseño de una clase hay que tener en cuenta —entre otros— el principio de responsabilidad única1, intentando que los atributos y los métodos que contenga esa clase estén enfocados a un objetivo único y bien definido.
Creando clases¶
Empecemos por crear nuestra primera clase. Durante todo este bloque pondremos ejemplos de droides de la saga StarWars.
(1)
Para crear una clase en Python hay que utilizar la palabra reservada class seguida del nombre de la clase:
- Los nombres de las clases se suelen escribir en singular y con formato
CamelCase. - La sentencia
passno hace nada, es simplemente un «placeholder».
Creando objetos¶
Existen multitud de droides en el universo StarWars. Una vez que hemos definido la clase genérica podemos crear instancias/objetos (droides) concretos:
Añadiendo métodos¶
Un método es una función que forma parte de una clase o de un objeto. En su ámbito tiene acceso a otros métodos y atributos de la clase o del objeto al que pertenece.
La definición de un método (de instancia) es análoga a la de una función ordinaria, pero incorporando un primer parámetro self que hace referencia a la instancia del objeto actual.
Veamos un ejemplo sobre los droides. Una de las acciones más sencillas que se pueden hacer sobre un droide es encenderlo o apagarlo. La implementación podría ser algo así:
>>> class Droid:#(1)!
... def switch_on(self):
... print("Hi! I'm a droid. Can I help you?")
...
... def switch_off(self):
... print("Bye! I'm going to sleep")
...
- Por simplicidad llamaremos
Droida la clase de aquí en adelante.
Ahora ya podríamos utilizar estos métodos recién creados:
>>> k2so = Droid()
>>> k2so.switch_on()
Hi! I'm a droid. Can I help you?
>>> k2so.switch_off()
Bye! I'm going to sleep
Orden de los métodos
El orden de definición de métodos dentro de la clase —a priori— no es importante.
Añadiendo atributos¶
Un atributo no es más que una variable, un nombre al que asignamos un valor, con la particularidad de vivir dentro de una clase o de un objeto.
Supongamos por ejemplo que queremos guardar el estado del droide (encendido/apagado):
>>> class Droid:
... def switch_on(self):
... self.power_on = True#(1)!
... print("Hi! I'm a droid. Can I help you?")
...
... def switch_off(self):
... self.power_on = False#(2)!
... print("Bye! I'm going to sleep")
...
- Para que una variable se convierta en atributo de un objeto, debemos usar el prefijo
self. - Modificamos el valor del atributo en función del método.
Probemos este código para ver su comportamiento:
>>> k2so = Droid()
>>> k2so.switch_on()
Hi! I'm a droid. Can I help you?
>>> k2so.power_on
True
>>> k2so.switch_off()
Bye! I'm going to sleep
>>> k2so.power_on
False
Inicialización¶
Existe un método especial que se ejecuta cuando creamos una instancia de un objeto. Este método es __init__ y nos permite asignar atributos y realizar operaciones con el objeto en el momento de su creación. También es ampliamente conocido como el constructor.
Veamos un ejemplo de este método con nuestros droides en el que únicamente guardaremos el nombre del droide como un atributo del objeto:
>>> class Droid:
... def __init__(self, name: str):#(1)!
... print('Running __init__')#(2)!
... self.name = name#(3)!
...
>>> bb8 = Droid('BB-8')#(4)!
Running __init__
>>> bb8.name#(5)!
'BB-8'
-
- El constructor recibe el nombre del droide.
- Obviamente también recibe
selfpero eso es generalizado.
- Este mensaje es únicamente a efectos académicos.
- Creamos un atributo y le asignamos el nombre indicado en el parámetro.
- Al «llamar» a la clase se está invocando el método
__init__() - Ahora tendremos acceso al atributo
namedel objeto creado en el constructor.
Es importante tener en cuenta que si no usamos self estaremos creando una variable local en vez de un atributo del objeto:
>>> class Droid:
... def __init__(self, name: str):
... name = name # 🤔
...
>>> bb8 = Droid('BB-8')
>>> bb8.name
Traceback (most recent call last):
Cell In[3], line 1
bb8.name
AttributeError: 'Droid' object has no attribute 'name'
Ejercicio
pypas mobile-phone
Atributos¶
En esta sección se tratará en profundidad todo lo relacionado con los atributos.
Acceso directo¶
En el siguiente ejemplo vemos que, aunque el atributo name se ha creado en el constructor de la clase, también podemos modificarlo desde «fuera» con un acceso directo:
>>> class Droid:
... def __init__(self, name: str):
... self.name = name
...
>>> droid = Droid('C-3PO')
>>> droid.name
'C-3PO'
>>> droid.name = 'waka-waka'#(1)!
- Esto sería válido.
Python nos permite añadir atributos dinámicamente a un objeto incluso después de su creación:
Propiedades¶
Aunque el uso de propiedades puede ir destinado a la «privacidad» de ciertos atributos, lo cierto es que en la mayoría de ocasiones, las utilizamos como valores calculados.
Mediante el decorador @property indicamos que un método se convierte en propiedad.
A modo de ejemplo, supongamos que la altura del periscopio de los droides astromecánicos se calcula siempre como un porcentaje de su altura:
>>> class AstromechDroid:
... def __init__(self, name: str, height: float):#(1)!
... self.name = name
... self.height = height
...
... @property#(2)!
... def periscope_height(self) -> float:#(3)!
... return 0.3 * self.height#(4)!
...
- El constructor recibe el nombre del droide y su altura.
- Indicamos la creación de una propiedad.
- Se aplica sobre un método que devuelve la altura del periscopio del droide (valor flotante).
- 30% de la altura del droide.
Ahora veamos su aplicación práctica:
- El acceso se realiza como una atributo, no se «llama» al método.
Una propiedad no puede modificarse2:
>>> droid.periscope_height = 0.645
Traceback (most recent call last):
Cell In[1], line 1
droid.periscope_height = 0.645
AttributeError: property 'periscope_height' of 'AstromechDroid' object has no setter
Las propiedades no pueden recibir parámetros ya que ni siquiera pueden ser invocadas:
>>> class AstromechDroid:
... def __init__(self, name: str, height: float):
... self.name = name
... self.height = height
...
... @property
... def periscope_height(self, from_ground: bool = False) -> float:
... height_factor = 1.3 if from_ground else 0.3
... return height_factor * self.height
...
>>> droid = AstromechDroid('R2-D2', 1.05)
>>> droid.periscope_height(from_ground=True)#(1)!
Traceback (most recent call last):
Cell In[3], line 1
droid.periscope_height(from_ground=True)
TypeError: 'float' object is not callable
- En este caso tendríamos que implementar un método para resolver el escenario planteado.
Valores calculados
La ventaja de usar valores calculados sobre simples atributos es que el cambio de valor en un atributo no asegura que actualicemos otro atributo, y además siempre podremos modificar directamente el valor del atributo, con lo que podríamos obtener efectos colaterales indeseados.
Cacheando propiedades¶
En los casos anteriores hemos creado una propiedad que calcula el alto del periscopio de un droide astromecánico a partir de su altura. El «coste» de este cálculo es bajo, pero imaginemos por un momento que fuera muy alto. Si cada vez que accedemos a dicha propiedad tenemos que realizar ese cálculo, estaríamos siendo muy ineficientes (en el caso de que la altura del droide no cambiara).
A continuación se muestra un ejemplo en el que usamos cacheado de propiedades para evitar ciertos cálculos innecesarios:
>>> class AstromechDroid:
... def __init__(self, name: str, height: float):
... self.name = name
... self.height = height#(1)!
...
... @property
... def height(self) -> float:
... return self._height#(2)!
...
... @height.setter#(3)!
... def height(self, height: float) -> None:
... self._height = height#(4)!
... self._periscope_height = None#(5)!
...
... @property
... def periscope_height(self) -> float:
... if self._periscope_height is None:#(6)!
... print('Calculating periscope height...')
... self._periscope_height = 0.3 * self.height
... return self._periscope_height#(6)!
- Lo que ocurre aquí es que se hace una llamada al «setter»
@height.setter. - Se utiliza un atributo
_heightpara fijar la altura (de forma interna). - El decorador
@height.setterhace que el método se llame cuando se asigne un valor al atributoheight. - Modificación del atributo
_heightmediante el «setter». - Cuando la altura del droide cambia, es necesario invalidar la caché.
- Sólo si se ha invalidado la caché se recalcula la altura del periscopio.
- Se devuelve el atributo (interno) que lleva la altura del periscopio.
Probemos ahora la implementación diseñada:
>>> droid = AstromechDroid('R2-D2', 1.05)
>>> droid.periscope_height#(1)!
Calculating periscope height...
0.315
>>> droid.periscope_height#(2)!
0.315
>>> droid.height = 1.15
>>> droid.periscope_height#(3)!
Calculating periscope height...
0.345
>>> droid.periscope_height#(4)!
0.345
- La altura del droide ha cambiado (asignación en el constructor), por lo tanto se recalcula la altura del periscopio.
- La altura del droide no ha cambiado, por lo tanto se devuelve la altura del periscopio precalculada.
- La altura del droide ha cambiado (asignación manual), por lo tanto se recalcula la altura del periscopio.
- La altura del droide no ha cambiado, por lo tanto se devuelve la altura del periscopio precalculada.
Ocultando atributos¶
Python tiene una convención sobre aquellos atributos que queremos hacer «privados» (u ocultos): comenzar el nombre con doble subguión __
A continuación se presenta un ejemplo ocultando el nombre del droide:
A la hora de acceder a este atributo obtendríamos un error:
>>> droid = Droid('BC-44')
>>> droid.__name
Traceback (most recent call last):
Cell In[2], line 1
droid.__name
AttributeError: 'Droid' object has no attribute '__name'
Lo que realmente ocurre tras el telón se conoce como «name mangling» y consiste en modificar el nombre del atributo incorporando la clase como un prefijo. Sabiendo esto podemos acceder al valor del atributo supuestamente privado:
Atributos de clase¶
Hasta ahora hemos visto atributos de objeto pero también es posible crear atributos de clase. Estos serán asumidos por todos los objetos instanciados a partir de dicha clase.
A modo de ejemplo podemos suponer que todos los droides están diseñados para que obedezcan a su dueño. Esto lo podemos conseguir a nivel de clase, salvo que ese comportamiento se quisiera sobreescribir:
>>> class Droid:
... obeys_owner = True#(1)!
...
>>> good_droid = Droid()
>>> good_droid.obeys_owner#(2)!
True
>>> t1000 = Droid()
>>> t1000.obeys_owner
True
>>> t1000.obeys_owner = False#(3)!
>>> t1000.obeys_owner
False
>>> Droid.obeys_owner#(4)!
True
-
- Un atributo de clase se define dentro de la clase asignándole un valor inicial.
- Habitualmente van antes que los métodos.
- Cualquier objeto creado contendrá este atributo de clase.
- Python permite la modificación del atributo de clase (para una instancia concreta).
- El cambio no afecta a nivel global de la clase.
Acceso
Los atributos de clase son accesibles tanto desde la clase como desde las instancias creadas.
Hay que tener en cuenta lo siguiente:
- Si modificamos un atributo de clase desde un objeto, sólo modificamos el valor en el objeto y no en la clase.
- Si modificamos un atributo de clase desde una clase, modificamos el valor en todos los objetos pasados y futuros.
Veamos un ejemplo de este segundo caso:
>>> class Droid:
... obeys_owner = True
...
>>> droid1 = Droid()
>>> droid1.obeys_owner
True
>>> droid2 = Droid()
>>> droid2.obeys_owner
True
>>> Droid.obeys_owner = False#(1)!
>>> droid1.obeys_owner
False
>>> droid2.obeys_owner
False
>>> droid3 = Droid()
>>> droid3.obeys_owner
False
- Cambia pasado y futuro.
La explicación de este fenómeno es la siguiente Todas las instancias (pasadas y futuras) del droide tienen un «atributo» obeys_owner que «apunta» a la misma zona de memoria que la del atributo obeys_owner de la clase:
>>> id(Droid.obeys_owner)
4385213672
>>> id(droid1.obeys_owner)
4385213672
>>> id(droid2.obeys_owner)
4385213672
>>> id(droid3.obeys_owner)
4385213672
Supongamos que tras el cambio «global» de obeys_owner lo que buscamos es que sólo se modifiquen los droides futuros pero no los pasados.
Para poder abordar este escenario debemos recurrir a atributos de instancia:
>>> class Droid:
... obeys_owner = True
...
... def __init__(self):
... self.obeys_owner = Droid.obeys_owner#(1)!
...
- En este punto se crea un atributo propio del objeto creado, que toma el valor del atributo de clase, pero se desvincula de su posición de memoria.
Ahora veamos cuál es su comportamiento:
>>> droid1 = Droid()
>>> droid1.obeys_owner
True
>>> droid2 = Droid()
>>> droid2.obeys_owner
True
>>> Droid.obeys_owner = False
>>> droid1.obeys_owner
True
>>> droid2.obeys_owner
True
>>> droid3 = Droid()
>>> droid3.obeys_owner
False
Métodos¶
En esta sección se tratará en profundidad todo lo relacionado con los métodos.
Métodos de instancia¶
Un método de instancia es un método que accede o modifica el estado del objeto al que hace referencia. Recibe self como primer parámetro, el cual se convierte en el propio objeto sobre el que estamos trabajando. Python envía este argumento de forma transparente: no hay que pasarlo como argumento.
Veamos un ejemplo en el que, además del constructor, creamos un método de instancia para hacer que un droide se mueva:
>>> class Droid:
... def __init__(self, name: str):#(1)!
... self.name = name
... self.covered_distance = 0
...
... def move_up(self, steps: int) -> None:#(2)!
... self.covered_distance += steps
... print(f'Moving {steps} steps')
...
- El constructor también es un método de instancia.
- Método de instancia para mover el droide.
Veamos su comportamiento:
Propiedades vs Métodos¶
Es razonable plantearse cuándo usar propiedades o cuándo usar métodos de instancia. Si la implementación requiere de parámetros, no hay confusión, necesitamos usar métodos.
Pero más allá de esto, no existe una respuesta clara y concisa a la pregunta. Aunque sí podemos dar algunas «pistas» para saber cuándo usar propiedades o cuándo usar métodos:
Métodos de clase¶
Un método de clase es un método que accede o modifica el estado de la clase a la que hace referencia. Recibe cls como primer parámetro, el cual se convierte en la propia clase (una referencia) sobre la que estamos trabajando. Python envía este argumento de forma transparente. La identificación de estos métodos se completa aplicando el decorador @classmethod a la función.
Veamos un ejemplo en el que implementamos un método de clase que muestra el número de droides creados:
>>> class Droid:
... count = 0#(1)!
...
... def __init__(self):
... Droid.count += 1#(2)!
...
... @classmethod#(3)!
... def get_total_droids(cls) -> None:#(4)!
... print(f'{cls.count} droids built so far!')#(5)!
...
- Se trata de una variable de clase.
- Incrementamos la variable de clase cada vez que se «construye» nuevo droide.
- Uso del decorador que define un método de clase.
- Los métodos de clase siempre reciben como primer parámetro
clshaciendo referencia a la propia clase. - Accedemos a la cuenta de droides construidos.
Probemos el código anterior:
>>> droid1 = Droid()
>>> droid2 = Droid()
>>> droid3 = Droid()
>>> Droid.get_total_droids()
3 droids built so far!
Métodos estáticos¶
Un método estático es un método que no «debería» modificar el estado del objeto ni de la clase. No recibe ningún parámetro especial. La identificación de estos métodos se completa aplicando el decorador @staticmethod a la función.
Veamos un ejemplo en el que creamos un método estático para devolver las categorías de droides que existen en StarWars:
>>> class Droid:
... def __init__(self, name: str):
... self.name = name
...
... @staticmethod#(1)!
... def get_droid_categories() -> tuple[str]:#(2)!
... return ('MESSENGER', 'ASTROMECH', 'POWER', 'PROTOCOL')
...
>>> Droid.get_droid_categories()
('MESSENGER', 'ASTROMECH', 'POWER', 'PROTOCOL')
- Uso del decorador para especificar que es un método estático.
- El método no recibe ningún parámetro especial.
Decoradores en clases¶
Hay escenarios en los que puede interesar aplicar decoradores propios en métodos de una clase. El enfoque es el mismo que ya se ha visto en la sección de decoradores pero con ciertos matices. Un decorador dentro de una clase debe ser un método estático.
A continuación veremos un ejemplo en el que creamos un decorador para comprobar que el droide está encendido antes de realizar determinadas operaciones:
>>> class Droid:
... def __init__(self, name: str):
... self.name = name
... self.powered = False
...
... @staticmethod#(1)!
... def power_required(method):#(2)!
... def wrapper(self, *args, **kwargs):#(3)!
... if self.powered:#(4)!
... return method(self, *args, **kwargs)#(5)!
... else:
... print('Droid must be powered to perform this action!')
... return None
... return wrapper
...
... def power_on(self):
... self.powered = True
...
... def power_off(self):
... self.powered = False
...
... @power_required#(6)!
... def greet(self):
... print(f"Hi there! I'm {self.name} at your service")
...
- Un decorador debe ser un método estático.
-
- Habitualmente usamos
funccomo parámetro. - En este caso tiene sentido usar
methodya que es una clase. - Al fin y al cabo sólo son convenciones.
- Habitualmente usamos
- Es un «wrapper» habitual.
- Sólo se llamará al método decorado si el droide está encendido.
-
- Sólo en este caso la llamada al método cambia.
self.method(*args, **kwargs)method(self, *args, **kwargs)- De no hacerlo así obtendríamos un error:
AttributeError: 'Droid' object has no attribute 'method'
- Aplicamos el decorador sobre este método.
Vamos ahora a poner en funcionamiento el código anterior y comprobar que el decorador está funcionando correctamente:
>>> droid = Droid('B1')
>>> droid.greet()#(1)!
Droid must be powered to perform this action!
>>> droid.power_on()
>>> droid.greet()#(2)!
Hi there! I'm B1 at your service
- El droide está apagado.
- El droide está encendido.
Encapsulamiento
El decorador también se podría implementar fuera de la clase. Por una cuestión de encapsulamiento podría tener sentido dejarlo dentro de la clase como método estático.
Métodos mágicos¶
Cuando escribimos 'hello world' * 3 ¿cómo sabe el objeto 'hello world' lo que debe hacer para multiplicarse con el objeto entero 3? O dicho de otra forma, ¿cuál es la implementación del operador * para «strings» e «int»? En valores numéricos puede parecer evidente (siguiendo los operadores matemáticos clásicos), pero no es así para otros objetos. La solución que proporciona Python para estas (y otras) situaciones son los métodos mágicos.
Los métodos mágicos empiezan y terminan por doble subguión __ (es por ello que también se les conoce como «dunder-methods»). Uno de los «dunder-methods» más famosos ya lo hemos visto y es el constructor de la clase: __init__().
Los métodos mágicos se «disparan» automágicamente automáticamente cuando utilizamos ciertas estructuras y expresiones del lenguaje.
Operadores¶
Para el caso de los operadores también existe un método mágico asociado (que podemos personalizar). Por ejemplo la comparación de dos objetos lanza el método mágico __eq__():
flowchart LR
code["<tt>a == b</tt>"] --->|⚡| eq["<tt>\_\_eq__</tt>"]
Extrapolando esta idea a nuestro universo StarWars, podríamos establecer que dos droides son iguales si su nombre es igual, independientemente de que tengan distintos números de serie:
>>> class Droid:
... def __init__(self, name: str, serial_number: int):
... self.name = name
... self.serial_number = serial_number
...
... def __eq__(self, droid: Droid) -> bool:#(1)!
... return self.name == droid.name#(2)!
...
-
- El argumento que recibimos es el droide con el que comparar.
- Para evitar un error de tipo (en la anotación)
NameError: name 'Droid' is not definedes necesario importar esto:from __future__ import annotations
- La comparación se realiza a nivel de nombre de droide.
Comprobemos entonces si dos droides son iguales:
>>> droid1 = Droid('C-3PO', 43974973242)
>>> droid2 = Droid('C-3PO', 85094905984)
>>> droid1 == droid2#(1)!
True
- Llamada implícita a:
droid1.__eq__(droid2)
¿Pero qué pasaría si tratamos de comparar un droide con «cualquier otra cosa»?
>>> droid1 == 'C-3PO'
Traceback (most recent call last):
Cell In[8], line 1
droid1 == 'C-3PO'
Cell In[1], line 7 in __eq__
return self.name == droid.name
AttributeError: 'str' object has no attribute 'name'
Obtendremos un error ya que un objeto de tipo «string» no dispone de un atributo name. Para resolver esto debemos cribar el objeto que vamos a comparar en función de su naturaleza:
>>> class Droid:
... def __init__(self, name: str, serial_number: int):
... self.name = name
... self.serial_number = serial_number
...
... def __eq__(self, other) -> bool:
... if isinstance(other, Droid):#(1)!
... return self.name == droid.name
... return False
...
- Si es un droide lo tratamos como tal, en otro caso, los objetos no pueden ser iguales.
Ahora se puede comprobar que todo funciona según lo esperado:
>>> droid1 = Droid('IG-88', 56548988761)
>>> droid2 = Droid('HK-47', 56548988761)
>>> droid1 == 'IG-88'
False
>>> droid1 == droid2
True
A continuación se presenta una tabla con métodos mágicos para operadores:
-
Operadores de comparación
Operador Método mágico __eq____ne____lt____gt____le____ge__ -
Operadores aritméticos
Operador Método mágico __add____sub____mul____truediv__(1)__mod____pow__
- La división entera
//lanza el método mágico__floordiv__
Métodos especiales
Los métodos mágicos no sólo están restringidos a operadores de comparación o aritméticos. Existen muchos otros en la documentación oficial de Python, donde son llamados métodos especiales.
Veamos otro ejemplo en el que «sumamos» dos droides (esto se podría ver como una fusión). Supongamos que la suma de dos droides implica: a) que el nombre del droide resultante es la concatenación de los nombres de los droides de entrada; b) que la energía del droide resultante es la suma de la energía de los droides de entrada:
>>> class Droid:
... def __init__(self, name: str, power: int):
... self.name = name
... self.power = power
...
... def __add__(self, other):
... if isinstance(other, Droid):
... new_power = self.power + other.power#(1)!
... elif isinstance(other, int):
... new_power = self.power + other#(2)!
... else:
... new_power = self.power#(3)!
... return Droid(self.name, new_power)
...
- Suma de droide con droide.
- Suma de droide con entero.
- Suma de droide con cualquier otro tipo de dato.
Probamos ahora el operador en distintos escenarios:
>>> droid1 = Droid('L3-37', 75)
>>> droid2 = Droid('M5-BZ', 47)
>>> droid = droid1 + droid2
>>> droid.power
122
>>> droid = droid1 + 25
>>> droid.power
100
>>> droid = droid2 + 'starwars'
>>> droid.power
47
__str__¶
Uno de los métodos mágicos más utilizados es __str__ y permite establecer la forma en la que un objeto es representado como cadena de texto.
Siguiendo con el ejemplo del droide, veamos una implementación de este método:
>>> class Droid:
... def __init__(self, name: str, serial_number: int):
... self.serial_number = serial_number
... self.name = name
...
... def __str__(self):#(1)!
... return f'🤖 Droid "{self.name}" serial-no {self.serial_number}'
...
- Este método siempre debe devolver un
str.
Existen tres ocasiones en las que se «dispara» el método mágico __str__():
>>> print(droid)#(1)!
🤖 Droid "K-2SO" serial-no 8403898409432
>>> str(droid)#(2)!
'🤖 Droid "K-2SO" serial-no 8403898409432'
>>> f'Droid --> {droid}'#(3)!
'Droid --> 🤖 Droid "K-2SO" serial-no 8403898409432'
- Imprimir el valor de un objeto.
- Convertir a cadena de texto.
- Interpolar un «f-string».
__repr__¶
¿Por qué sale esto al ver el contenido de un objeto en un intérprete interactivo de Python ❯❯❯?
Porque el método mágico que se ejecuta cuando mostramos un objeto en el intérprete es __repr__(1) y suele estar más enfocado a una representación «técnica» del mismo.
- También cuando usamos la función «built-in»
repr().
Ejercicio
pypas fraction
Gestores de contexto¶
Otra de las aplicaciones interesantes de los métodos mágicos/especiales son los gestores de contexto. Un gestor de contexto permite aplicar una serie de acciones a la entrada y a la salida del bloque de código que engloba.
Hay dos métodos que son utilizados para implementar los gestores de contexto:
| Método | Descripción |
|---|---|
__enter__ |
Acciones que se llevan a cabo al entrar al contexto. |
__exit__ |
Acciones que se llevan a cabo al salir al contexto. |
Podemos encontrar gestores de contexto...
A continuación se presenta un ejemplo que muestra mensajes al comienzo y al final del contexto:
>>> class Greeting:#(1)!
... def __enter__(self):#(2)!
... print('Dear user, welcome to this context manager 👋')
...
... def __exit__(self, exc_type, exc_value, exc_traceback):#(3)!
... print('Bye bye! Have a nice day 🌻')
...
- Se trata de una clase como otra cualquiera.
- Acciones a realizar a la entrada del contexto.
-
- Acciones a realizar a la salida del contexto.
- Es obligatorio definir los parámetros para control de excepciones:
exc_typecontendrá el tipo de la excepción,exc_valueel valor de la excepción yexc_tracebackla traza de la excepción. - Si el contexto terminó sin ningún error, los argumentos anteriores valdrán
None.
Ahora podemos probar nuestro gestor de contexto:
A continuación se presenta un ejemplo que borra el rastro de un droide.
Lo primero será definir la clase del droide:
>>> class Droid:
... def __init__(self, name: str):
... self.name = name
... self.steps = []
...
... def move(self, x: int, y: int) -> None:
... self.steps.append((x, y))
...
Ahora crearemos el gestor de contexto en sí mismo:
>>> class LazyDroid:
... def __init__(self, name: str):#(1)!
... self.droid = Droid(name)
...
... def __enter__(self, name: str):#(2)!
... return self.droid
...
... def __exit__(self, exc_type, exc_value, exc_traceback):
... self.droid.steps.clear()
...
- Los gestores de contexto también puede incluir constructor.
- Devolvemos el droide creado.
- Borramos sus pasos.
Por último probemos la implementación:
>>> with LazyDroid('KT-QT') as droid:
... droid.move(3, 7)
... droid.move(4, 4)
... droid.move(2, 9)
... droid.move(5, 1)
...
>>> droid.steps#(1)!
[]
- Los pasos se han borrado gracias al gestor de contexto.
Herencia¶
La herencia es un mecanismo de OOP que consiste en construir una nueva clase partiendo de otra ya existente, añadiendo o modificando ciertos aspectos. La herencia se considera una buena práctica de programación tanto para reutilizar código como para realizar generalizaciones.
Se denomina clase base a la clase desde la que se hereda y subclase a la clase que ha heredado:
Mecanismo de herencia¶
Para que una clase «herede» de otra, basta con indicar la clase base entre paréntesis en la definición de la subclase.
Empecemos con un ejemplo sencillo en el que un droide de protocolo hereda de un droide:
- Clase base.
- Por simplicidad no se ha añadido ningún código en la clase.
- Subclase.
- Por simplicidad no se ha añadido ningún código en la clase.
Hecho esto, podemos comprobar que realmente se cumple la herencia:
- ¿Es
ProtocolDroiduna subclase deDroid?
Vamos ahora a añadir cierta funcionalidad a la clase base:
>>> class Droid:
... def switch_on(self):
... print("Hi! I'm a droid. Can I help you?")
...
... def switch_off(self):
... print("Bye! I'm going to sleep")
...
>>> class ProtocolDroid(Droid):
... pass
...
Y comprobemos cuál es el comportamiento en función de la herencia definida:
>>> r2d2 = Droid()#(1)!
>>> c3po = ProtocolDroid()#(2)!
>>> r2d2.switch_on()#(3)!
Hi! I'm a droid. Can I help you?
>>> r2d2.switch_off()#(4)!
Bye! I'm going to sleep
>>> c3po.switch_on()#(5)!
Hi! I'm a droid. Can I help you?
>>> c3po.switch_off()#(6)!
Bye! I'm going to sleep
- Creación de una instancia de droide (clase base).
- Creación de una instancia de droide de protocolo (subclase).
- Método definido en la clase base.
- Método definido en la clase base.
- Método definido en la clase base pero no en la subclase. Al haber heredado se puede utilizar sin ningún inconveniente.
- Método definido en la clase base pero no en la subclase. Al haber heredado se puede utilizar sin ningún inconveniente.
Sobreescribir un método¶
Como hemos visto, la subclase hereda todo lo que contiene su clase base. Pero hay ocasiones en las que nos interesa modificar este comportamiento.
En el siguiente ejemplo vamos a personalizar el saludo de la subclase de droide de protocolo:
>>> class Droid:
... def switch_on(self):
... print("Hi! I'm a droid. Can I help you?")
...
... def switch_off(self):
... print("Bye! I'm going to sleep")
...
>>> class ProtocolDroid(Droid):
... def switch_on(self):
... print("Hi! I'm a PROTOCOL droid. Can I help you?")
...
Veamos cómo afecta este cambio al comportamiento de los objetos creados:
>>> r2d2 = Droid()#(1)!
>>> c3po = ProtocolDroid()#(2)!
>>> r2d2.switch_on()#(3)!
Hi! I'm a droid. Can I help you?
>>> r2d2.switch_off()#(4)!
Bye! I'm going to sleep
>>> c3po.switch_on()#(5)!
Hi! I'm a PROTOCOL droid. Can I help you?
>>> c3po.switch_off()#(6)!
Bye! I'm going to sleep
- Creación de una instancia de droide (clase base).
- Creación de una instancia de droide de protocolo (subclase).
- Método definido en la clase base.
- Método definido en la clase base.
- Método definido en la clase base y en la subclase. Se aplica el de la subclase ya que sobreescribe al de la clase base.
- Método definido en la clase base pero no en la subclase. Al haber heredado se puede utilizar sin ningún inconveniente.
Añadir un método¶
La subclase puede, como cualquier otra clase «ordinaria», añadir métodos que no estaban presentes en su clase base.
En el siguiente ejemplo vamos a añadir un método translate() que permita a los droides de protocolo traducir cualquier mensaje:
>>> class Droid:
... def switch_on(self):
... print("Hi! I'm a droid. Can I help you?")
...
... def switch_off(self):
... print("Bye! I'm going to sleep")
...
>>> class ProtocolDroid(Droid):
... def switch_on(self):
... print("Hi! I'm a PROTOCOL droid. Can I help you?")
...
... def translate(self, msg: str, *, from_lang: str) -> str:
... """ Translate from language to Human understanding """
... return f'{msg} means "ZASCA" in {from_lang}'
...
Probemos ahora esta implementación:
>>> r2d2 = Droid()
>>> c3po = ProtocolDroid()
>>> c3po.translate('kiitos', from_lang='Hutese')#(1)!
'kiitos means "ZASCA" in Hutese'
>>> r2d2.translate('kiitos', from_lang='Huttese')#(2)!
Traceback (most recent call last):
Cell In[4], line 1
r2d2.translate('kiitos', from_lang='Huttese')
AttributeError: 'Droid' object has no attribute 'translate'
- Método definido en la subclase.
-
- Este método sólo está definido en la subclase con lo cual no se puede utilizar desde la clase base.
- Tiene sentido que falle ya que los droides que no son de protocolo no pueden traducir.
Con esto ya hemos aportado una personalidad diferente a los droides de protocolo, a pesar de que heredan de la clase genérica de droides de StarWars.
Resolver colisiones¶
Cuando tenemos métodos (o atributos) definidos con el mismo nombre en la clase base y en la clase derivada se produce una colisión y debe existir un mecanismo para diferenciarlos.
Para estos casos Python nos ofrece super() como función para acceder a métodos (o atributos) de la clase base.
Este escenario es especialmente recurrente en el constructor de aquellas clases que heredan de otras y necesitan inicializar la clase base.
Veamos un ejemplo más elaborado con nuestros droides:
>>> class Droid:
... def __init__(self, name: str):
... self.name = name
...
>>> class ProtocolDroid(Droid):
... def __init__(self, name: str, languages: list[str]):
... super().__init__(name)#(1)!
... self.languages = languages
...
- Llamada al constructor de la clase base.
Probemos ahora la implementación anterior:
>>> droid = ProtocolDroid('C-3PO', ['Ewokese', 'Huttese', 'Jawaese'])
>>> droid.name#(1)!
'C-3PO'
>>> droid.languages#(2)!
['Ewokese', 'Huttese', 'Jawaese']
- Fijado en el constructor de la clase base.
- Fijado en el constructor de la subclase.
Herencia múltiple¶
Aunque no está disponible en todos los lenguajes de programación, Python sí permite heredar de múltiples clases base.
Supongamos por ejemplo que queremos modelar la siguiente estructura de clases con herencia múltiple:
La forma de definir herencia múltiple en Python es indicar las clases base dentro del paréntesis:
>>> class Droid:
... def greet(self):
... return 'Here a droid'
...
>>> class ProtocolDroid(Droid):
... def greet(self):
... return 'Here a protocol droid'
...
>>> class AstromechDroid(Droid):
... def greet(self):
... return 'Here an astromech droid'
...
>>> class SuperDroid(ProtocolDroid, AstromechDroid):
... pass
...
>>> class HyperDroid(AstromechDroid, ProtocolDroid):
... pass
Orden de herencia
El orden en el que especificamos las clases base influye en el resultado final.
Podemos comprobar esta herencia múltiple de la siguiente manera:
>>> issubclass(SuperDroid, (ProtocolDroid, AstromechDroid, Droid))#(1)!
True
>>> issubclass(HyperDroid, (AstromechDroid, ProtocolDroid, Droid))#(2)!
True
-
- Un «superdroide» es una subclase de «droide de protocolo», «droide astromecánico» y «droide».
- La función
issubclass()también funciona con varias clases pasando una tupla.
-
- Un «hiperdroide» es una subclase de «droide de astromecánico», «droide de protocolo» y «droide».
- La función
issubclass()también funciona con varias clases pasando una tupla.
¿Cómo se comportarán los métodos definidos en esta herencia múltiple?
>>> super_droid = SuperDroid()
>>> hyper_droid = HyperDroid()
>>> super_droid.greet()#(1)!
'Here a protocol droid'
>>> hyper_droid.greet()#(2)!
'Here an astromech droid'
- El método
greet()más «cercano» en alguna clase base deSuperDroidsería el deProtocolDroid. - El método
greet()más «cercano» en alguna clase base deHyperDroidsería el deAstromechDroid.
Orden de resolución de métodos¶
Si en una clase se hace referencia a un método o atributo que no existe, Python lo buscará en todas sus clases base. Pero es posible que exista una colisión en caso de que el método o el atributo buscado esté, a la vez, en varias clases base. En este caso, Python resuelve el conflicto a través del orden de resolución de métodos.
Todas las clases en Python disponen de un método especial llamado mro() «method resolution order» que devuelve una lista de las clases que se visitarían en caso de acceder a un método o a un atributo.
Podemos comprobar este hecho en el ejemplo anterior de herencia múltiple de droides:
>>> SuperDroid.mro()
[<class '__main__.SuperDroid'>,
<class '__main__.ProtocolDroid'>,
<class '__main__.AstromechDroid'>,
<class '__main__.Droid'>,
<class 'object'>]
>>> HyperDroid.mro()
[<class '__main__.HyperDroid'>,
<class '__main__.AstromechDroid'>,
<class '__main__.ProtocolDroid'>,
<class '__main__.Droid'>,
<class 'object'>]
Todos los objetos en Python heredan, en primera instancia, de object. Esto se puede comprobar con el correspondiente mro() de cada objeto:
>>> int.mro()
[int, object]
>>> str.mro()
[str, object]
>>> float.mro()
[float, object]
>>> tuple.mro()
[tuple, object]
>>> list.mro()
[list, object]
>>> bool.mro()#(1)!
[bool, int, object]
- Es por este motivo que podemos encontrar comportamientos «numéricos» de valores booleanos.
Otra forma de comprobar lo anteriormente dicho sería ejecutar el siguiente fragmento de código:
>>> PY_TYPES = (int, str, float, tuple, list, bool)
>>> all(issubclass(_type, object) for _type in PY_TYPES)
True
Mixins¶
Hay situaciones en las que nos interesa incorporar una clase base «independiente» de la jerarquía establecida, y sólo a efectos de tareas auxiliares o transversales. Esta aproximación podría ayudar a evitar colisiones en métodos o atributos reduciendo la ambigüedad que añade la herencia múltiple. A estas clases auxiliares se las conoce como «mixins».
Veamos une ejemplo de un «mixin» que permite dejar registro de las operaciones realizadas:
>>> class LoggerMixin:#(1)!
... def log(self, message):#(2)!
... print(f'[LOG] {message}')
...
>>> class Droid:#(3)!
... def __init__(self):
... self.pos = (0, 0)
...
>>> class ProtocolDroid(Droid, LoggerMixin):#(4)!
... def move(self, x: int, y: int) -> None:
... self.log(f'Moving droid to ({x}, {y})')#(5)!
... self.pos = (x, y)
...
- Un «mixin» debería ser algo genérico y no acoplado al contexto del problema.
- Se define un método para mostrar mensajes.
- Esta clase sería la clase base de la jerarquía propiamente del contexto del problema
- Este clase hereda de la clase base
Droidpero a su vez le otorgamos funcionalidades extra mediante el «mixin»LoggerMixin. - Podemos hacer uso del método
log()ya que se ha heredado del «mixin»LoggerMixin.
Comprobemos el funcionamiento del código anterior:
>>> droid = ProtocolDroid()
>>> droid.pos
(0, 0)
>>> droid.move(7, 2)
[LOG] Moving droid to (7, 2)
>>> droid.pos
(7, 2)
Ejercicio
pypas file-inheritance
Agregación y composición¶
Aunque la herencia de clases nos permite modelar una gran cantidad de casos de uso en términos de «is-a» (es un), existen muchas otras situaciones en las que la agregación o la composición son una mejor opción. En este último caso una clase se compone de otras clases: hablamos de una relación «has-a» (tiene un).
-
Agregación
La agregación implica que el objeto utilizado puede funcionar por sí mismo.
«Una bicicleta tiene una linterna»
-
Composición
La composición implica que el objeto utilizado no puede «funcionar» sin la presencia de su propietario.
«Un ordenador tiene una CPU»
Veamos un ejemplo de cada uno de ellos en el contexto de los droides de StarWars:
Hay droides que pueden ir «armados» con ciertas herramientas:
>>> class Tool:
... def __init__(self, name: str):
... self.name = name
...
... def __str__(self):
... return self.name.upper()
...
>>> class Droid:
... def __init__(self, name: str, serial_number: int, tool: Tool = None):#(1)!
... self.name = name
... self.serial_number = serial_number
... self.tool = tool#(2)!
...
... def __str__(self):
... msg = f'Droid {self.name}'
... if self.tool:
... msg += f' armed with a {self.tool}'
... return msg
...
- En un modelo de agregación el objeto (herramienta) es opcional.
- Agregación de una herramienta al droide.
Veamos una aplicación de la implementación anterior:
Todos los droides necesitan una batería para funcionar:
>>> class Battery:
... def __init__(self, kw: float):
... self.kw = kw
...
... def __str__(self):
... return f'{self.kw}kW BATTERY'
...
>>> class Droid:
... def __init__(self, name: str, serial_number: int, battery: Battery):#(1)!
... self.name = name
... self.serial_number = serial_number
... self.battery = battery#(2)!
...
... def __str__(self):
... return f'Droid {self.name} built with a {self.battery}'
...
- En un modelo de composición el objeto (batería) es requerido.
- Composición de una batería en el droide.
Veamos una aplicación de la implementación anterior:
Estructuras mágicas¶
Obviamente no existen estructuras mágicas, pero sí que hay estructuras de datos que deben implementar ciertos métodos mágicos (o especiales) para desarrollar su comportamiento.
En este apartado veremos algunas de ellas.
Secuencias¶
Una secuencia en Python es un objeto en el que podemos acceder a cada uno de sus elementos a través de un índice, así como calcular su longitud total.
Algunos ejemplos de secuencias que ya se han visto incluyen cadenas de texto, listas o tuplas.
Una secuencia debe implementar, al menos, los siguientes métodos mágicos:
flowchart LR
S((Secuencias)) --> A
S --> B
S --> C
A["<tt>obj[0]</tt>"] <-.-> getitem{{"<tt>obj.\_\_getitem__(0)</tt>"}}
B["<tt>obj[1] = value</tt>"] <-.-> setitem{{"<tt>obj.\_\_setitem__(1, value)</tt>"}}
C["<tt>len(obj)</tt>"] <-.-> len{{"<tt>obj.\_\_len__()</tt>"}}
Como ejemplo, podemos asumir que los droides de StarWars están ensamblados con distintas partes/componentes. Veamos una implementación de este escenario:
>>> class Droid:
... def __init__(self, name: str, parts: list[str]):
... self.name = name
... self.parts = parts
...
... def __getitem__(self, index: int) -> str:
... return self.parts[index]
...
... def __setitem__(self, index: int, part: str) -> None:
... self.parts[index] = part
...
... def __len__(self):
... return len(self.parts)
...
Ahora podemos instanciar la clase anterior y probar su comportamiento:
>>> droid = Droid('R2-D2', ['Radar Eye', 'Pocket Vent', 'Battery Box'])
>>> droid.parts
['Radar Eye', 'Pocket Vent', 'Battery Box']
>>> droid[0]#(1)!
'Radar Eye'
>>> droid[1]#(2)!
'Pocket Vent'
>>> droid[2]#(3)!
'Battery Box'
>>> droid[1] = 'Holographic Projector'#(4)!
>>> droid.parts
['Radar Eye', 'Holographic Projector', 'Battery Box']
>>> len(droid)#(5)!
3
droid.__getitem__(0)droid.__getitem__(1)droid.__getitem__(2)droid.__setitem__(1, 'Holographic Projector')droid.__len__()
Ejercicio
pypas infinite-list
Diccionarios¶
Los métodos __getitem__() y __setitem()__ también se pueden aplicar para obtener o fijar valores en un estructura de tipo diccionario. La diferencia es que en vez de manejar un índice manejamos una clave.
Retomando el ejemplo anterior de las partes de un droide vamos a plantear que cada componente tenga asociada una versión, lo que nos proporciona una estructura de tipo diccionario con clave (nombre de la parte) y valor (versión de la parte):
>>> class Droid:
... def __init__(self, name: str, parts: dict[str, float]):
... self.name = name
... self.parts = parts
...
... def __getitem__(self, part: str) -> float | None:
... return self.parts.get(part)
...
... def __setitem__(self, part: str, version: float) -> None:
... self.parts[part] = version
...
... def __len__(self):
... return len(self.parts)
Ahora podemos instanciar la clase anterior y probar su comportamiento:
>>> droid = Droid(
... 'R2-D2',
... {
... 'Radar Eye': 1.1,
... 'Pocket Vent': 3.0,
... 'Battery Box': 2.8
... }
... )
>>> droid.parts
{'Radar Eye': 1.1, 'Pocket Vent': 3.0, 'Battery Box': 2.8}
>>> droid['Radar Eye']#(1)!
1.1
>>> droid['Pocket Vent']#(2)!
3.0
>>> droid['Battery Box']#(3)!
2.8
>>> droid['Pocket Vent'] = 3.1#(4)!
>>> droid.parts
{'Radar Eye': 1.1, 'Pocket Vent': 3.1, 'Battery Box': 2.8}
>>> len(droid)#(5)!
3
droid.__getitem__('Radar Eye')droid.__getitem__('Pocker Vent')droid.__getitem__('Battery Box')droid.__setitem__('Pocker Vent', 3.1)droid.__len__()
Iterables¶
Un objeto en Python se dice iterable si implementa el protocolo de iteración. Este protocolo permite «entregar» un valor del iterable cada vez que se «solicite».
Hay muchos tipos de datos iterables en Python que ya se han estudiado: cadenas de texto, listas, tuplas, conjuntos, diccionarios o ficheros.
Para ser un objeto iterable sólo es necesario implementar el método mágico __iter__. Este método debe proporcionar una referencia al objeto iterador que es quien se encargará de desarrollar el protocolo de iteración a través del método mágico __next__.
Veamos un ejemplo del universo StarWars partiendo de un modelo sencillo de droide:
>>> class Droid:
... def __init__(self, serial: int):
... self.serial = serial
...
... def __repr__(self):
... return f'Droid: SN={self.serial}'
...
A continuación implementamos una factoría de droides (Geonosis) como un iterable:
>>> class Geonosis:
... def __init__(self, num_droids: int):
... self.num_droids = num_droids
... self.pointer = 0
...
... def __iter__(self) -> object:#(1)!
... return self
...
... def __next__(self) -> Droid:#(2)!
... if self.pointer >= self.num_droids:
... raise StopIteration
... droid = Droid(self.pointer)
... self.pointer += 1
... return droid
...
- En este caso el iterador es el propio objeto, pero podría haber sido otro.
- Protocolo de iteración.
Ahora podemos recorrer el iterable y obtener los droides que genera la factoría:
>>> for droid in Geonosis(10):
... print(droid)
...
Droid: SN=0
Droid: SN=1
Droid: SN=2
Droid: SN=3
Droid: SN=4
Droid: SN=5
Droid: SN=6
Droid: SN=7
Droid: SN=8
Droid: SN=9
Cuando utilizamos un bucle for para recorrer los elementos de un iterable, ocurren varias cosas:
- Se obtiene el objeto iterador del iterable mediante
iter(). - Se hacen llamadas sucesivas a
next()sobre dicho iterador para obtener cada elemento del iterable. - Se para la iteración cuando el iterador lanza la excepción
StopIteration(protocolo de iteración).
Ejercicio
pypas fibonacci-iterable
Iterables desde fuera¶
Ahora que conocemos las interiodades de los iterables, podemos ver qué ocurre si los usamos desde un enfoque más funcional.
En primer lugar hay que conocer el uso de los métodos mágicos en el protocolo de iteración:
__iter()__se invoca cuando se hace uso de la funcióniter().__next()__se invoca cuando se hace uso de la funciónnext().
Teniendo esto en cuenta, probemos este ejemplo para generar droides de una forma más «artesanal»:
>>> factory = Geonosis(3)#(1)!
>>> factory_iterator = iter(factory)#(2)!
>>> next(factory_iterator)#(3)!
Droid: SN=0
>>> next(factory_iterator)#(4)!
Droid: SN=1
>>> next(factory_iterator)#(5)!
Droid: SN=2
>>> next(factory_iterator)#(6)!
Traceback (most recent call last):
Cell In[6], line 1
next(factory_iterator)
Cell In[], line 11 in __next__
raise StopIteration
StopIteration
- Factoria para construir 3 droides.
- Obtenemos el objeto iterador
factory.__iter__() - Pedimos el siguiente droide
factory_iterator.__next__() - Pedimos el siguiente droide
factory_iterator.__next__() - Pedimos el siguiente droide
factory_iterator.__next__() - No hay más droides, con lo cual el protocolo de iteración acaba con
StopIteration.
Se da la circunstancia de que, en este caso, no tenemos que crear el iterador para poder obtener nuevos elementos:
Esto se debe básicamente a que el iterador es el propio iterable:
>>> geon_iterable = Geonosis(3)
>>> geon_iterator = iter(geon_iterable)
>>> geon_iterable is geon_iterator
True
Otra característica importante es que los iterables se agotan. Lo podemos comprobar con el siguiente código:
>>> geon = Geonosis(3)
>>> for droid in geon:
... print(droid)
...
Droid: SN=0
Droid: SN=1
Droid: SN=2
>>> for droid in geon:#(1)!
... print(droid)
-
- El puntero
geon.pointervale 3, y por tanto ya no puede generar más droides. - Tendríamos que volver a inicializar el iterable.
- El puntero
Ejemplos de iterables¶
Vamos a analizar herramientas ya vistas —entendiendo mejor su funcionamiento interno— en base a lo que ya sabemos sobre iterables.
>>> tool = enumerate([1, 2, 3])
>>> iter(tool) is not None#(1)!
True
>>> iter(tool) == tool#(2)!
True
>>> next(tool)
(0, 1)
>>> next(tool)
(1, 2)
>>> next(tool)
(2, 3)
>>> next(tool)
Traceback (most recent call last):
Cell In[7], line 1
next(tool)
StopIteration
- Es iterable.
- Es su propio iterador.
>>> tool = range(1, 4)
>>> iter(tool) is not None#(1)!
True
>>> iter(tool) == tool#(2)!
False
>>> tool_iterator = iter(tool)
>>> tool_iterator
<range_iterator at 0x1100e6d60>
>>> next(tool_iterator)
1
>>> next(tool_iterator)
2
>>> next(tool_iterator)
3
>>> next(tool_iterator)
Traceback (most recent call last):
Cell In[7], line 1
next(tool_iterator)
StopIteration
- Es iterable.
- Usa otro iterador.
Objetos de tipo rango
Los objetos de tipo range representan una secuencia inmutable de números. La ventaja de usar este tipo de objetos es que siempre se usa una cantidad fija (y pequeña) de memoria, independientemente del rango que represente (ya que solamente necesita almacenar los valores para start, stop y step, y calcula los valores intermedios a medida que los va necesitando).
>>> tool = reversed([1, 2, 3])
>>> iter(tool) is not None#(1)!
True
>>> iter(tool) == tool#(2)!
True
>>> next(tool)
3
>>> next(tool)
2
>>> next(tool)
1
>>> next(tool) # protocolo de iteración!
Traceback (most recent call last):
Cell In[7], line 1
next(tool)
StopIteration
- Es iterable.
- Es su propio iterador.
>>> tool = zip([1, 2], [3, 4])
>>> iter(tool) is not None#(1)!
True
>>> iter(tool) == tool#(2)!
True
>>> next(tool)
(1, 3)
>>> next(tool)
(2, 4)
>>> next(tool) # protocolo de iteración!
Traceback (most recent call last):
Cell In[6], line 1
next(tool)
StopIteration
- Es iterable.
- Es su propio iterador.
>>> def seq(n):
... for i in range(1, n+1):
... yield i
...
>>> tool = seq(3)
>>> iter(tool) is not None#(1)!
True
>>> iter(tool) == tool#(2)!
True
>>> next(tool)
1
>>> next(tool)
2
>>> next(tool)
3
>>> next(tool) # protocolo de iteración!
Traceback (most recent call last):
Cell In[8], line 1
next(tool)
StopIteration
- Es iterable.
- Es su propio iterador.
Expresiones generadoras
Las mismas propiedades se aplican a expresiones generadoras.
>>> tool = [1, 2, 3]
>>> iter(tool) is not None#(1)!
True
>>> iter(tool) == tool#(2)!
False
>>> tool_iterator = iter(tool)
>>> tool_iterator
<list_iterator at 0x1102492d0>
>>> next(tool_iterator)
1
>>> next(tool_iterator)
2
>>> next(tool_iterator)
3
>>> next(tool_iterator)
Traceback (most recent call last):
Cell In[9], line 1
next(tool_iterator)
StopIteration
- Es iterable.
- Usa otro iterador.
>>> tool = tuple([1, 2, 3])
>>> iter(tool) is not None#(1)!
True
>>> iter(tool) == tool#(2)!
False
>>> tool_iterator = iter(tool)
>>> tool_iterator
<tuple_iterator at 0x107255a50>
>>> next(tool_iterator)
1
>>> next(tool_iterator)
2
>>> next(tool_iterator)
3
>>> next(tool_iterator)
Traceback (most recent call last):
Cell In[9], line 1
next(tool_iterator)
StopIteration
- Es iterable.
- Usa otro iterador.
>>> tool = 'abc'
>>> iter(tool) is not None#(1)!
True
>>> iter(tool) == tool#(2)!
False
>>> tool_iterator = iter(tool)
>>> tool_iterator
<str_ascii_iterator at 0x1078da7d0>
>>> next(tool_iterator)
'a'
>>> next(tool_iterator)
'b'
>>> next(tool_iterator)
'c'
>>> next(tool_iterator)
Traceback (most recent call last):
Cell In[9], line 1
next(tool_iterator)
StopIteration
- Es iterable.
- Usa otro iterador.
>>> tool = {'a': 1, 'b': 1}
>>> iter(tool) is not None#(1)!
True
>>> iter(tool) == tool#(2)!
False
>>> tool_iterator = iter(tool)
>>> tool_iterator
<dict_keyiterator at 0x1070200e0>
>>> next(tool_iterator)
'a'
>>> next(tool_iterator)
'b'
>>> next(tool_iterator)
Traceback (most recent call last):
Cell In[8], line 1
next(tool_iterator)
StopIteration
- Es iterable.
- Usa otro iterador.
En el caso de los diccionarios existen varios iteradores disponibles:
>>> tool = set([1, 2, 3])
>>> iter(tool) is not None#(1)!
True
>>> iter(tool) == tool#(2)!
False
>>> tool_iterator = iter(tool)
>>> tool_iterator
<set_iterator at 0x10700e900>
>>> next(tool_iterator)
1
>>> next(tool_iterator)
2
>>> next(tool_iterator)
3
>>> next(tool_iterator)
Traceback (most recent call last):
Cell In[9], line 1
next(tool_iterator)
StopIteration
- Es iterable.
- Usa otro iterador.
A continuación se presenta una tabla resumen de lo explicado anteriormente:
| Herramienta | Es iterable | Propio iterador | Múltiples iteradores |
|---|---|---|---|
enumerate() |
|||
reversed() |
|||
zip() |
|||
generator |
|||
file |
|||
list() |
|||
tuple() |
|||
str() |
|||
dict() |
|||
set() |
|||
range() |
Generadores como iterables¶
Como bien se ha visto en el apartado anterior, los generadores son iterables (e iteradores) en sí mismos. Nos podemos valer de esta propiedad para simplificar los artefactos implementados.
Veamos un ejemplo de factoría de droides usando generadores en el protocolo de iteración:
>>> class Geonosis:
... def __init__(self, num_droids: int):
... self.droids = (Droid(i) for i in range(num_droids))#(1)!
...
... def __iter__(self) -> object:
... return iter(self.droids)#(2)!
...
-
- Usamos una expresión generadora para construir los droides.
- Al ser un generador los droides no son creados en este punto, sino cuando se requieran (comportamiento «lazy»).
- El iterador que vamos a utilizar es el iterador del generador.
Probemos la implementación anterior:
Listas como iterables
A nivel de rendimiento y escalabilidad, hay que entender que no es lo mismo utilizar una lista que un generador, aunque en muchos escenarios puede ser más que suficiente.
A continuación se propone un ejemplo de la factoría de droides mediante lista:
>>> class Geonosis:
... def __init__(self, num_droids: int):
... self.droids = [Droid(i) for i in range(num_droids)]#(1)!
...
... def __iter__(self) -> object:
... return iter(self.droids)#(2)!
...
>>> for droid in Geonosis(3):
... print(droid)
...
Droid: SN=0
Droid: SN=1
Droid: SN=2
-
- Usamos una lista para construir los droides.
- Al ser una lista todos los droides son creados en este punto del código.
- El iterador que vamos a utilizar es el iterador de la lista.
Ejercicio
pypas fibonacci-itergen
Anatomía de una clase¶
Durante toda la sección hemos analizado con detalle los distintos componentes que forman una clase en Python. Pero cuando todo esto lo ponemos junto puede suponer un pequeño caos organizativo.
Aunque no existe ninguna indicación formal de la estructura de una clase, podríamos establecer el siguiente formato como guía de estilo:
class CustomClass:
"""Descripción de la clase"""
# ATRIBUTOS DE CLASE
# CONSTRUCTOR
# MÉTODOS MÁGICOS
# PROPIEDADES
# MÉTODOS DE INSTANCIA
# MÉTODOS DE CLASE
# MÉTODOS ESTÁTICOS
# DECORADORES