martes, 18 de enero de 2022

Programación Orientada a Objetos con Python.


Imagen de una señal con el texto POO


¿Qué es la programación orientada a objetos en Python?


Python es un lenguaje que soporta tanto la forma tradicional de programar, como es la programación funcional en el que el código se va ejecutando de forma secuencial, como también la programación orientada a objetos. Esta programación orientada a objetos cumple con los cuatro paradigmas de este tipo de programación como son:


- Encapsulamiento.

- Herencia.

- Polimorfismo.

- Abstracción.


Vamos a verlo de forma global y practicaremos estas características en esta píldora.

Básicamente una clase es una plantilla de la cual vamos a poder crear instancias u objetos. La clase representa un grupo de características comunes de un grupo de objetos. Mientras que la instancia es un ejemplar que pertenece a una determinada clase. Como este tema es un poco abstracto vamos a verlo con un ejemplo.

Imaginemos que tenemos una librería. En esa librería vendemos libros de muchas clases pero una de ellas, son libros de poesía. Traspasando esto al ámbito informático, los libros de poesía serían de una clase. Esos libros tienen una serie de características comunes como son el título, el número de poemas, el autor y el precio. Cada uno de los diferentes libros de poesía que tenemos serían objetos, libros distintos, que son instancias de la clase Poesía. En informática cuando definimos un objeto decimos que estamos instanciando ese objeto de una clase.

Vamos a crear un clase llamada Poesía para un supuesto software que lleve las ventas de una librería. 

class Poesia:
    def __init__(self, titulo, poemas, autor, precio):
        self.titulo = titulo
        self.poemas = poemas
        self.autor = autor
        self.precio = precio


La clase poesía se inicializa usando el método especial __init__ al que tenemos que pasar una serie de parámetros que aunque podemos llamarlos de la manera que queramos, lo normal es que coincidan con los atributos de la clase. En este caso los atributos de la clase Poesía son titulo, el número de poemas, autor y precio.

Es una costumbre en Python que las clases definidas por el usuario estén en formato "Camello" es decir con la primera letra en Mayúscula y si hubiera otra palabra más, en mayúscula la primera letra también. Si nuestra clase se llamará libros de poesía, en formato "Camello" su nombre de clase sería LibrosPoesia. Además el archivo donde se guarda, en el disco duro, esta clase suele tener el mismo nombre que la clase.

Con esta clase que tenemos, se pueden crear o mejor dicho instanciar tantos objetos (representaciones de libros en nuestro caso) como queramos. Por ejemplo vamos a instanciar tres objetos.


libro_1 = Poesia("cantigas desconocidas", 323, "Fray Perico", 10)
libro_2 = Poesia("yo y mi llama", 125, "Gloria Strong", 15)
libro_3 = Poesia("juramento perdido", 232, "Benito Vercimuelle", 8)


libro_1, libro_2 y libro_3 son objetos distintos. Tenemos tres instancias distintas de la clase poesía. El termino "self" que aparece en la construcción de la clase hace referencia a las instancias correspondientes (objetos), es decir hace referencia al objeto que se va a crear.


print(libro_1)
print(libro_2)
print(libro_3)

Salida:

<__main__.Poesia object at 0x7f0d655af4c0>
<__main__.Poesia object at 0x7f0d654591f0>
<__main__.Poesia object at 0x7f0d65459430>

Al mandar imprimir los objetos vemos que Python nos indica a que clase pertenecen y su posición en memoria. Sin embargo estaría mejor ver algo más de información sobre el objeto lo cual se puede lograr usando un método especial llamado __repr__. Un método especial en Python es aquel que empieza y termina por dos guiones bajos y que se llama bajo determinadas circunstancias.  En algunos sitios aparecen este tipo de métodos como de tipo "dunder" del inglés "double underscore".

Resumiendo:

class Poesia:
    def __init__(self, titulo, poemas, autor, precio):
        self.titulo = titulo
        self.poemas = poemas
        self.autor = autor
        self.precio = precio
        
    def __repr__(self):
        return f"Libro de Poesia: {self.titulo} by {self.autor},\
{self.precio} €"

# Instanciando o creando los objetos.
libro_1 = Poesia("cantigas desconocidas", 323, "Fray Perico", 10)
libro_2 = Poesia("yo y mi llama", 125, "Gloria Strong", 15)
libro_3 = Poesia("juramento perdido", 232, "Benito Vercimue", 8)

#imprimiendo los objetos 
print(libro_1)
print(libro_2)
print(libro_3)

Salida:

Libro de Poesia: cantigas desconocidas by Fray Perico,10 €
Libro de Poesia: yo y mi llama by Gloria Strong,15 €
Libro de Poesia: juramento perdido by Benito Vercimue,8 €


Y así como las clases tienen atributos, también pueden tener métodos. Esos métodos son acciones o funciones que modifican los objetos de las clases. Si por ejemplo queremos aplicar un descuento a un determinado libro, podemos crear un método llamado descontar que aplique el descuento al precio de ese libro.


class Poesia:
    def __init__(self, titulo, poemas, autor, precio):
        self.titulo = titulo
        self.poemas = poemas
        self.autor = autor
        self.precio = precio

    def descontar(self, porcentaje):
        self.precio = self.precio * (1 - porcentaje/100)
        
    def __repr__(self):
        return f"Libro de Poesia: {self.titulo} by {self.autor},\
{self.precio} €"

# Instanciando o creando los objetos.
libro_1 = Poesia("cantigas desconocidas", 323, "Fray Perico", 10)
# Aplicando el descuento.
libro_1.descontar(5)
print(libro_1.precio)

Salida

9.5


Encapsulación.

La encapsulación es el proceso de hacer que ciertos atributos sean inaccesibles fuera del código de la propia clase y solo pueda accederse a ellos a través de ciertos métodos.

Estos atributos se denominan privados y comienzan con dos guiones bajos. (También puedes en los programas de Python ciertos atributos que comienzan con un solo guion que aunque no son estrictamente privados el programador considera que no deben se modificados fuera de la clase a la que pertenecen). 

Cuando se ponen dos guiones bajos delante del nombre de métodos, suelen ser funciones auxiliares de alguna de los métodos principales de la clase, que queremos que solo se puedan usar dentro de la misma.

En nuestra clase Poesia vamos a convertir el atributo precio en un atributo privado (__precio) y vamos a intentar imprimirlo desde fuera de la clase para ver que tipo de error nos da.

class Poesia:
    def __init__(self, titulo, poemas, autor, precio):
        self.titulo = titulo
        self.poemas = poemas
        self.autor = autor
        self.__precio = precio

    def descontar(self, porcentaje):
        self.__precio = self.__precio * (1 - porcentaje/100)
        
    def __repr__(self):
        return f"Libro de Poesia: {self.titulo} by {self.autor},\
{self.__precio} €"

# Instanciando o creando los objetos.
libro_1 = Poesia("cantigas desconocidas", 323, "Fray Perico", 10)
# Aplicando el descuento.
libro_1.descontar(5)
print(libro_1.__precio)

Salida:

Traceback (most recent call last):
  File "main.py", line 19, in <module>
    print(libro_1.__precio)
AttributeError: 'Poesia' object has no attribute '__precio'


Existen varias formas de asignar valores a atributos privados. Uno de ellos es a través de métodos llamados setter y getter. Como en el ejemplo convertí la variable precio en privada, vamos a crear un método setter para establecer el precio y un método getter para leerlo.

class Poesia:
    def __init__(self, titulo, poemas, autor, precio):
        self.titulo = titulo
        self.poemas = poemas
        self.autor = autor
        self.__precio = precio

    def get_precio(self):
        ''' Método getter para leer el precio.'''
        return self.__precio

    def set_precio(self, precio):
        ''' Método setter para establecer el precio.'''
        self.__precio = precio      

    def descontar(self, porcentaje):
        self.__precio = self.__precio * (1 - porcentaje/100)
        
    def __repr__(self):
        return f"Libro de Poesia: {self.titulo} by {self.autor},\
{self.__precio} €"

# Instanciando o creando los objetos.
libro_1 = Poesia("cantigas desconocidas", 323, "Fray Perico", 10)
# Cambiamos el precio a 20 
libro_1.set_precio(20)
# Aplicando el descuento.
libro_1.descontar(5)
print(libro_1.get_precio())
Salida:

19.0

Otra forma de realizar lo mismo es a través de decoradores. Al usar clases se recomienda que en ciertas circunstancias se oculte el estado interno de los objetos al exterior, para impedir que sean manipulados de manera incorrecta. A continuación vemos como se puede utilizar el decorador @property para modificar un método y que funcione como un atributo, pero solo en modo lectura. La estructura para ello es la siguiente: 

decoradores para encapsulación de clases


class Poesia:
    def __init__(self, titulo, poemas, autor, precio):
        self.titulo = titulo
        self.poemas = poemas
        self.autor = autor
        self.__precio = precio

    @property
    def precio(self):
        return self.__precio
      
    @precio.setter
    def precio(self, precio):
        if precio != "":
            print("modificando el precio.")
            self.__precio = precio
        else:
            print("El precio esta vacio.")
      

    def descontar(self, porcentaje):
        self.__precio = self.__precio * (1 - porcentaje/100)
        
    def __repr__(self):
        return f"Libro de Poesia: {self.titulo} by {self.autor},\
{self.__precio} €"

# Instanciando o creando los objetos.
libro_1 = Poesia("cantigas desconocidas", 323, "Fray Perico", 10)
# Cambiamos el precio a 20 
libro_1.precio = 20
# Aplicando el descuento.
libro_1.descontar(5)
print(libro_1.precio)

Salida:

modificando el precio.
19.0

Observa como en este ejemplo establecemos el precio del libro como un atributo, no como si fuera un método, como hicimos en el primer ejemplo. Y lo mismo hacemos para ver el precio del libro. Usamos un atributo y no como en el primer ejemplo que usamos un método (). Podemos decir que estos decoradores transforman métodos para que puedan usarse como si fueran atributos.

Herencia.


La herencia se considera la característica más importante en la programación orientada a objetos. La Herencia es la capacidad que tiene una clase para heredar atributos y/o métodos de otra. La clase heredera se denomina clase hija, subclase o clase secundaria. La clase de la que se heredan los métodos se denomina clase padre o superclase. Si existen atributos o métodos con el mismo nombre prevalecen los de la clase hija sobre los de la padre. 

El software de ventas imaginario que teníamos para la librería ahora va a poder con dos objetos más como son las obras de teatro y las novelas. Podemos ver que si un libro pertenece a la categoría Poesía, Teatro o Novela puede tener atributos que son comunes a las otras categorías de libros como pueden ser los campos: título, autor o incluso el método para ver su precio o poner un descuento. Sería una perdida de tiempo, esfuerzo y memoria el tener que escribir de nuevo el código para cada una de las materias.

Por lo tanto, lo que vamos a hacer es crear una superclase llamada Libros de las cuales Poesía, Teatro y Novela heredaran una atributos y métodos comunes.

class Libros:
    def __init__(self, titulo, autor, precio):
        self.titulo = titulo
        self.autor = autor
        self.__precio = precio

    @property
    def precio(self):
        return self.__precio
      
    @precio.setter
    def precio(self, precio):
        if precio != "":
            print("modificando el precio.")
            self.__precio = precio
        else:
            print("El precio esta vacio.")

    def descontar(self, porcentaje):
        self.__precio = self.__precio * (1 - porcentaje/100)        
        print(f'Aplicando un descuento del {porcentaje} %')
Ahora vamos a modificar la clase Poesía que ya teníamos anteriormente creada para que herede de la clase Libros.

class Poesia(Libros):
    def __init__(self, titulo, autor, precio, poemas):
        super().__init__(titulo, autor, precio)
        # También se podrían pasar los parámetros a la clase padre en vez
        # de usar el comando super() como:
        # Libros.__init__(self, titulo, autor, precio)
        self.poemas = poemas
        
    def __repr__(self):
        return f"Libro de Poesia: {self.titulo} by {self.autor},\
 {self.precio} €"

Para ver mejor como funciona la herencia vamos a instanciar un objeto, variarle el precio y ponerle un descuento.

poem_1 = Poesia('Odas a un juguete', 'Perico', 33, 120)
poem_1.precio = 30
poem_1.descontar(20)
print(poem_1)
Salida:

modificando el precio.
Aplicando un descuento del 20 %
Libro de Poesia: Odas a un juguete by Perico, 24.0 €

Y de la misma forma que hemos hecho con la clase Poesia, lo hacemos con la clase Teatro y Novela.

Las crearemos para que hereden los atributos y métodos de la clase padre Libros.


class Teatro(Libros):
    def __init__(self, titulo, autor, precio, genero):       
        super().__init__(titulo, autor, precio)
        self.genero = genero
        
    def __repr__(self):
        return f"Libro de Teatro: {self.titulo} by {self.autor},\
 {self.precio} €"
 
class Novela(Libros):
    def __init__(self, titulo, autor, precio, paginas):
        super().__init__(titulo, autor, precio)
        self.paginas = paginas
        
    def __repr__(self):
        return f"Libro de Novelas: {self.titulo} by {self.autor},\
 {self.precio} €"

Y podemos comprobar como funciona creando dos nuevos objetos de cada clase:

teat_1 = Teatro('La celestina', 'Fernando de Rojas', 10, 'Clásico')
nove_1 = Novela('Nunca', 'Ken Follet', 30, 890)
print(teat_1)
print(nove_1)
Salida:

Libro de Teatro: La celestina by Fernando de Rojas, 10 €
Libro de Novelas: Nunca by Ken Follet, 30 €

Polimorfismo.


La palabra polimorfismo viene del griego y significa "Algo que puede tomar diferentes formas". Traduciendo esto al tema que nos ocupa, podemos decir que es la capacidad de una clase hija para modificar un método como se necesite que ya está presente en la clase padre. En otras palabra, es la capacidad de una subclase o clase hija de usar un método de la clase padre tal como está o modificarlo si es necesario.

Vamos a verlo con nuestro ejemplo en el que vamos a reconvertir un poco el código.

class Libros:
    def __init__(self, titulo, autor, precio):
        self.titulo = titulo
        self.autor = autor
        self.__precio = precio

    @property
    def precio(self):
        return self.__precio
      
    @precio.setter
    def precio(self, precio):
        if precio != "":
            print("modificando el precio.")
            self.__precio = precio
        else:
            print("El precio esta vacio.")

    def descontar(self, porcentaje):
        self.__precio = self.__precio * (1 - porcentaje/100)        
        print(f'Aplicando un descuento del {porcentaje} %')
        
    def __repr__(self):
        return f"Libro: {self.titulo} by {self.autor},\
 {self.precio} €"
 
        
class Poesia(Libros):
    def __init__(self, titulo, autor, precio, poemas):
        super().__init__(titulo, autor, precio)
        self.poemas = poemas
            
class Teatro(Libros):
    def __init__(self, titulo, autor, precio, genero):       
        super().__init__(titulo, autor, precio)
        self.genero = genero
        
class Novela(Libros):
    def __init__(self, titulo, autor, precio, paginas):
        super().__init__(titulo, autor, precio)
        self.paginas = paginas

    def __repr__(self):
         return f'{self.titulo} escrito por {self.autor} y tiene {self.paginas} páginas.,\
Precio {self.precio}'

Se puede ver como la clase padre Libros tiene un método especial  __repr__. Las clases hijas Teatro y Novela pueden usar ese método de la clase padre como tal, de forma que cada vez que se imprima un objeto de las mismas se invocará este método. Sin embargo la subclase Novela se define con su propio método especial __repr__. Por Polimorfismo, la subclase Novela usará su propio método ignorando o suprimiendo el mismo método que existe en la superclase.

En otras palabras las clases hijas heredan los atributos y métodos de la clase padre, pero si en ellas existe un atributo o método que se llame igual, prevalece el suyo propio sobre el de la clase paterna. Y al igual que la vida real los padres no heredan de los hijos.

poem_1 = Poesia('Odas a un juguete', 'Perico', 33, 120)
teat_1 = Teatro('La celestina', 'Fernando de Rojas', 10, 'Clásico')
nove_1 = Novela('Nunca', 'Ken Follet', 30, 890)
print(poem_1)
print(teat_1)
print(nove_1)
Salida:

Libro: Odas a un juguete by Perico, 33 €
Libro: La celestina by Fernando de Rojas, 10 €
Nunca escrito por Ken Follet y tiene 890 páginas.,Precio 30

Abstracción.


En Python no existe un soporte directo para la abstracción, esta debe realizarse a través de decoradores.

Si definimos un método abstracto en la clase Padre, obligamos a las clases hijas a agregar esta implementación en ellas. Y además ocurre también una cosa, que es que si en la clase padre creamos un método abstracto, el que sea, toda la clase se vuelve abstracta y no se pueden crear instancias u objetos de esa clase, podremos hacerlo de las clases hijas pero no de la padre.

La abstracción sirve para mantener una cierta estructura común en las subclases.

En nuestro ejemplo de software de venta de libros, en los ejemplos previos, hemos definido métodos __repr__  para todas las clases (en el apartado de herencia). Luego hemos definido un método __repr__ común en la clase padre que las clases hijas podían invocar si no tenían su propio método (en el apartado polimorfismo). Ahora en el siguiente ejemplo la clase padre va a tener un método __repr__ abstracto lo que obligará a las subclases a tener su propio método.

Para convertir el método __repr__ en abstracto, lo primero que tenemos que hacer es que la clase padre herede de una clase especial llamada ABC = Abstract Base Class y también necesitamos importar el decorador de abstractmethod.

#ABC = abstract base class
from abc import ABC, abstractmethod

class Libros(ABC):
    def __init__(self, titulo, autor, precio):
        self.titulo = titulo
        self.autor = autor
        self.__precio = precio

    @property
    def precio(self):
        return self.__precio
      
    @precio.setter
    def precio(self, precio):
        if precio != "":
            print("modificando el precio.")
            self.__precio = precio
        else:
            print("El precio esta vacio.")

    def descontar(self, porcentaje):
        self.__precio = self.__precio * (1 - porcentaje/100)        
        print(f'Aplicando un descuento del {porcentaje} %')
        
    @abstractmethod
    def __repr__(self):
        # No podemos código ya que solo queremos obligar a que las clases que hereden
        # de está tengan un método __repr__ propio -> Abstracion.
        pass
 
        
class Poesia(Libros):
    def __init__(self, titulo, autor, precio, poemas):
        super().__init__(titulo, autor, precio)
        self.poemas = poemas
            
class Teatro(Libros):
    def __init__(self, titulo, autor, precio, genero):       
        super().__init__(titulo, autor, precio)
        self.genero = genero
        
class Novela(Libros):
    def __init__(self, titulo, autor, precio, paginas):
        super().__init__(titulo, autor, precio)
        self.paginas = paginas

    def __repr__(self):
         return f'{self.titulo} escrito por {self.autor} y tiene {self.paginas} páginas.,\
Precio {self.precio}'
Lo primero que vamos a hacer es intentar instanciar un objeto de la clase padre Libros. Vamos a ver como al tener un método abstracto (__repr__) no nos va a dejar, devolviéndonos un error.

libro = Libros('un libro','desconocido', 1)
Salida:

Traceback (most recent call last):
  File "main.py", line 51, in <module>
    Libros('un libro','desconocido', 1)
TypeError: Can't instantiate abstract class Libros with abstract methods __repr__

Luego, intencionadamente no hemos creado un método __repr__ para las clases Poesia y Teatro con lo que estas al ser subclases de la clase Padre Libros y tener esta un método abstracto, nos dará un error al intentar instanciar alguno de estos objetos. Por obligación ambos deberían tener el método __repr__ ya que así lo hemos querido al poner el método abstracto en la clase padre.


poem_1 = Poesia('Odas a un juguete', 'Perico', 33, 120)
Salida:

Traceback (most recent call last):
  File "main.py", line 50, in <module>
    poem_1 = Poesia('Odas a un juguete', 'Perico', 33, 120)
TypeError: Can't instantiate abstract class Poesia with abstract methods __repr__
y lo mismo ocurriría con la clase Teatro. 

Con el código superior la única clase que no daría error al instanciar sería la clase novela que ya tiene el método __repr__ obligatorio.

La implementación correcta de la clase abstracta que tiene el método __repr__ abstracto es la siguiente. Basta con añadir el método __repr__ a la clase Poesia y Teatro.

#ABC = abstract base class
from abc import ABC, abstractmethod

class Libros(ABC):
    def __init__(self, titulo, autor, precio):
        self.titulo = titulo
        self.autor = autor
        self.__precio = precio

    @property
    def precio(self):
        return self.__precio
      
    @precio.setter
    def precio(self, precio):
        if precio != "":
            print("modificando el precio.")
            self.__precio = precio
        else:
            print("El precio esta vacio.")

    def descontar(self, porcentaje):
        self.__precio = self.__precio * (1 - porcentaje/100)        
        print(f'Aplicando un descuento del {porcentaje} %')
        
    @abstractmethod
    def __repr__(self):
        pass
 
        
class Poesia(Libros):
    def __init__(self, titulo, autor, precio, poemas):
        super().__init__(titulo, autor, precio)
        self.poemas = poemas
        
    def __repr__(self):
        return f"Libro: {self.titulo} by {self.autor},\
 {self.precio} €"
            
class Teatro(Libros):
    def __init__(self, titulo, autor, precio, genero):       
        super().__init__(titulo, autor, precio)
        self.genero = genero
        
    def __repr__(self):
        return f"Libro: {self.titulo} by {self.autor},\
 {self.precio} €"
        
class Novela(Libros):
    def __init__(self, titulo, autor, precio, paginas):
        super().__init__(titulo, autor, precio)
        self.paginas = paginas

    def __repr__(self):
         return f'{self.titulo} escrito por {self.autor} y tiene {self.paginas} páginas.,\
Precio {self.precio}'
Ahora si podemos crear los objetos sin errores:

poem_1 = Poesia('Odas a un juguete', 'Perico', 33, 120)
teat_1 = Teatro('La celestina', 'Fernando de Rojas', 10, 'Clásico')
nove_1 = Novela('Nunca', 'Ken Follet', 30, 890)
print(poem_1)
print(teat_1)
print(nove_1)
Salida:

Libro: Odas a un juguete by Perico, 33 €
Libro: La celestina by Fernando de Rojas, 10 €
Nunca escrito por Ken Follet y tiene 890 páginas.,Precio 30

Para aprender más sobre las clases puedes consultar la documentación oficial. 

Ahora que hemos visto la POO desde un punto de vista práctico vamos a profundizar un poco más viendo aspectos algo más teóricos.

Vamos a hacer un resumen de lo que hemos visto:

1. Una clase es una idea (más o menos abstracta) que se puede utilizar para crear varias encarnaciones; una encarnación de este tipo se denomina objeto.


2. Cuando una clase se deriva de otra clase, su relación se denomina herencia. La clase que deriva de la otra clase se denomina subclase. El segundo lado de esta relación se denomina superclase. Una forma de presentar dicha relación es en un diagrama de herencia, donde:

        Las superclases siempre se presentan encima de sus subclases.
        Las relaciones entre clases se muestran como flechas dirigidas desde la subclase hacia su superclase.

3. Los objetos están equipados con:

        Un nombre que los identifica y nos permite distinguirlos.
        Un conjunto de propiedades (el conjunto puede estar vacío).
        Un conjunto de métodos (también puede estar vacío).

4. Para definir una clase de Python, se necesita usar la palabra clave reservada class. Por ejemplo:

        class Esto_es_una_Clase:
            pass
 

5. Para crear un objeto de la clase previamente definida, se necesita usar la clase como si fuera una función. Por ejemplo:

        esto_es_un_objeto  = Esto_es_una_Clase()
 

Vamos a ver un ejemplo construyendo una estructura de datos que se conoce en programación con el nombre de Pila. Imagina una pila de monedas encima de la mesa, una encima de otra. Primero ponemos una moneda en la mesa, luego otra y así sucesivamente. Cuando queremos retirar una moneda de la pila de monedas tenemos que coger la última que hemos puesto, es decir la última que entra es la primera que sale. (LIFO - LAST IN FIRST OUT)

Comencemos desde el principio creando la clase:

class Pila:
Añadimos un constructor __init__ muy simple a nuestra clase.

class Pila:
    def __init__(self):
        print("Soy la clase Pila.")

un_objeto_pila = Pila()
Ahora:
  • el nombre del constructor es siempre __init__
  • Tiene que tener al menos un párametro, el cual se usa para representar al objeto recien creado. 
  • Este parámetro obligatorio generalmente se llama self, aunque puede tener cualquier otro nombre. No obstante por convenció, deberías llamarla self ya que esto facilita el proceso de lectura y comprensión del código.

Si ejecutas el código en un editor de Python esta sería la salida:

Soy la clase Pila.
Como has visto el constructor se ejecuta automáticamente al instanciar el objeto. Podemos aprovechar esto para añadir cualquier propiedad o atributo al objeto y este permanecerá allí hasta que el objeto termine su vida o la propiedad o atributo se elimine explícitamente.

Visto esto vamos a agregar una nueva propiedad al objeto a la que llamaremos lista_pila.

class Pila:
    def __init__(self):
        self.lista_pila = []

un_objeto_pila = Pila()
print(len(un_objeto_pila.lista_pila))
Por supuesto el código produce el siguiente resultado:

0
Nota:

  • Hemos usado el punto '. ' para acceder a las propiedades del objeto. Debes nombrar el objeto, poner un punto '.' después del él y especificar el nombre de la propiedad deseada. ¡No uses paréntesis! No quieres ejecutar un método si no acceder a una propiedad o atributo del objeto.
  • Si estableces el valor de una propiedad por primera vez (como pasa en el constructor) lo estás creando. A partir de ese momento el objeto tiene esa propiedad y esta lista para usarse.
  • Hemos accedido a la propiedad lista_pila desde fuera de la clase para verificar la longitud actual de la lista.

Sin embargo a nosotros nos gustaría que esa propiedad no fuera accesible desde fuera de la clase, para que nadie por error pudiera modificarla. Vamos a agregar dos guiones bajos delante del nombre de la propiedad a ver que pasa. (__lista_pila)

class Pila:
    def __init__(self):
        self.__lista_pila = []

un_objeto_pila = Pila()
print(len(un_objeto_pila.lista_pila))
Pues que tenemos un bonito error:

Traceback (most recent call last):
  File "main.py", line 6, in <module>
    print(len(un_objeto_pila.lista_pila))
AttributeError: 'Pila' object has no attribute 'lista_pila'
Cuando cualquier elemento de una clase, ya sea un atributo o un método tiene un nombre que comienza por dos guiones bajos (__), se vuelve privado (aunque con matices como veremos más adelante) lo que significa que solo se puede acceder a él desde dentro de la clase. A la capacidad de ocultar (proteger) los valores seleccionados contra el acceso no autorizado se llama encapsulamiento.

Ahora es momento de añadir dos métodos para agregar y quitar valores de la pila. Lo que significa que ambos deben ser accesibles desde fuera de la clase (en contraste con la lista previamente construida, que esta oculta para los usuarios de la clase). Tales componente serán públicos pues su nombre no va a empezar con dos guiones bajos.

class Pila:
    def __init__(self):
        self.__lista_pila = []
    
    def agregar(self, valor):
        self.__lista_pila.append(valor)
    
    def quitar(self):
        valor = self.__lista_pila[-1]
        del self.__lista_pila[-1]
        return valor

un_objeto_pila = Pila()

un_objeto_pila.agregar(3)
un_objeto_pila.agregar(2)
un_objeto_pila.agregar(1)

print(un_objeto_pila.quitar())
print(un_objeto_pila.quitar())
print(un_objeto_pila.quitar())
Salida:

1
2
3
Como ves las funciones que componen nuestros métodos tienen como primer parámetro el "self" y esto es obligatorio ya que Python automáticamente lo utiliza. Aunque tu método tenga más parámetros obligatoriamente debe tener el self como primer argumento. 

Y ahora que ya tenemos la clase construida podemos instanciar diferentes objetos que se van a comportar de la misma manera, tendrán sus propios datos privados independientes pero compartirán los mismos métodos.

class Pila:
    def __init__(self):
        self.__lista_pila = []
    
    def agregar(self, valor):
        self.__lista_pila.append(valor)
    
    def quitar(self):
        valor = self.__lista_pila[-1]
        del self.__lista_pila[-1]
        return valor

un_objeto_pila_1 = Pila()
un_objeto_pila_2 = Pila()

un_objeto_pila_1.agregar(3)
un_objeto_pila_2.agregar(un_objeto_pila_1.quitar())

print(un_objeto_pila_2.quitar())
Salida:

3    
Ahora vamos a ir un poco más lejos. Vamos a crear una nueva clase para manejar pilas. Esta nueva clase nos dará la suma de todos los elementos que hay almacenados actualmente en la pila. No queremos modificar la clase Pila, ya que funciona bien como está y no queremos que cambie de ninguna manera. Queremos una nueva pila con nueva capacidades, es decir queremos construir una subclase de la clase Pila existente.

El primer paso es fácil, crea una nueva clase que herede de la clase Pila:


class SumaPila(Pila):
    pass

Esta nueva clase SumaPila no tiene aún ningún componente nuevo pero esto no quiere decir que este vacia. Obtiene (Hereda) todos los componentes definidos por su superclase o clase padre. 

Esto es lo que queremos en esta nueva pila:

  • Que el método agregar no solo añada un valor a la pila, sino además sume su valor a una nueva variable que llamaremos suma.
  • Queremos que la función quitar no solo extraiga un valor de la pila, sino que reste el mismo de la variable suma.

Echa un vistazo al código:

class SumaPila(Pila):
    def __init__(self):
        Pila.__init__(self)
        self.__suma = 0

A continuación tenemos que añadir los métodos para agregar y quitar datos. ¿Pero realmente necesitamos agregarlos? La respuesta es que no. ¡Ya los tenemos en la superclase!

Lo que vamos a hacer es cambiar la funcionalidad de estos métodos, no sus nombres. 
Empecemos con el método agregar. Lo que esperamos que haga es:

  • Agrege el valor que le pasemos a la variable __sum.
  • Agrege el valor a la pila.
Este segundo punto ya lo tenemos en la superclase por lo que podemos usarla. Pero es que además tenemos que usarla ya que no hay otra forma de acceder a la variable __lista_pila.

Así es como se ve el método agregar de la subclase:

def agregar(self, valor):
        self.__suma += valor
        Pila.agregar(self, valor)
FIJATE como hemos invocado la implementación anterior del método agregar (el disponible en la superclase)

Con esto se dice que el método agregar ha sido anulado, ya que tiene el mismo nombre que en la superclase pero tiene una funcionalidad diferente.

Para el método quitar la cuestión es bastante parecida. 

def quitar(self):
        valor = Pila.quitar(self)
        self.__suma -= valor
        return valor
Bien, lo que tenemos también que tener en cuenta es que la variable __suma esta definida como privada por lo que fuera de la clase no podemos obtener su valor. Tenemos que obtenerla a través de otro método, lo que se llama "getter". Este nuevo método devolverá el valor de __suma.

def get_suma(self):
        return self.__suma

Con lo que hemos visto el programa quedaría así. Hagamos algunas pruebas para ver si funciona.

class Pila:
    def __init__(self):
        self.__lista_pila = []
    
    def agregar(self, valor):
        self.__lista_pila.append(valor)
    
    def quitar(self):
        valor = self.__lista_pila[-1]
        del self.__lista_pila[-1]
        return valor

class SumaPila(Pila):
    def __init__(self):
        Pila.__init__(self)
        self.__suma = 0
        
    def agregar(self, valor):
        self.__suma += valor
        Pila.agregar(self, valor)
        
    def quitar(self):
        valor = Pila.quitar(self)
        self.__suma -= valor
        return valor
    
    def get_suma(self):
        return self.__suma
        
objeto_1 = SumaPila()
for i in range(5):
    objeto_1.agregar(i)
print(objeto_1.get_suma())

for i in range(5):
    print(objeto_1.quitar())
La salida parece indicar que si. Agregamos cinco valores consecutivos a la pila. imprimimos su suma y los sacamos a todos de la pila.

Salida.

10
4
3
2
1

PROPIEDADES.


1.- Variables de instancia.

Este tipo de propiedad o atributo existe solo cuando se crea explícitamente y se agrega al objeto. Esto se puede hacer durante la inicialización del objeto, realizada por el constructor, o también se puede hacer en cualquier momento de la vida del objeto. Es importante señalar que también se puede eliminar en cualquier momento.

Estas son las variables de instancia que están estrechamente ligadas a los objetos (que son instancias de las clases), no a las clases mismas.

Veamos un ejemplo:

class Ejemplo:
    def __init__(self, valor = 1):
        self.primero = valor
        
    def set_segundo(self, valor):
        self.segundo = valor

ejemplo_objeto_1 = Ejemplo()

ejemplo_objeto_2 = Ejemplo(2)
ejemplo_objeto_2.set_segundo(3) 

ejemplo_objeto_3 = Ejemplo(4)
ejemplo_objeto_3.tercero = 5
# A este objeto sobre la marcha le hemos añadido una nueva propiedad (tercero)
# fuera del código de la clase.

print("El primer objeto tiene las propiedades ", ejemplo_objeto_1.__dict__)
print("El segundo objeto tiene las propiedades", ejemplo_objeto_2.__dict__)
print("El tercer objeto tiene las propiedades ", ejemplo_objeto_3.__dict__)
Salida:

El primer objeto tiene las propiedades  {'primero': 1}
El segundo objeto tiene las propiedades {'primero': 2, 'segundo': 3}
El tercer objeto tiene las propiedades  {'primero': 4, 'tercero': 5}
Antes de entrar en más detalles vamos a explicar un poco esto. Echa un vistazo a las tres últimas líneas del código.

Los objetos en Python, cuando se crean, están dotados de un pequeño conjunto de propiedades y métodos predefinidos. Uno de ellos es la variable __dict__ (que es un diccionario) 

Esta variable contiene los nombres y valores de todas las propiedades que el objeto tiene actualmente. 

Tiene que quedar claro que el modificar una variable de instancia de cualquier objeto no tiene impacto en todos los objetos restantes. Las variables de instancia están perfectamente aisladas unas de otras.

Probemos lo mismo pero  con una pequeña modificación.

class Ejemplo:
    def __init__(self, valor = 1):
        self.__primero = valor
        
    def set_segundo(self, valor = 2):
        self.__segundo = valor

ejemplo_objeto_1 = Ejemplo()

ejemplo_objeto_2 = Ejemplo(2)
ejemplo_objeto_2.set_segundo(3) 

ejemplo_objeto_3 = Ejemplo(4)
ejemplo_objeto_3.__tercero = 5

print("El primer objeto tiene las propiedades ", ejemplo_objeto_1.__dict__)
print("El segundo objeto tiene las propiedades", ejemplo_objeto_2.__dict__)
print("El tercer objeto tiene las propiedades ", ejemplo_objeto_3.__dict__)
Salida:

El primer objeto tiene las propiedades  {'_Ejemplo__primero': 1}
El segundo objeto tiene las propiedades {'_Ejemplo__primero': 2, '_Ejemplo__segundo': 3}
El tercer objeto tiene las propiedades  {'_Ejemplo__primero': 4, '__tercero': 5}
Es casi lo mismo que el anterior. La única diferencia está en los nombres de las propiedades. Hemos antepuesto dos guiones bajos (__). Como hemos visto esto hace que la variable sea privada, se vuelve inaccesible desde el mundo exterior ¿O no?

¿Puedes ver en la salida esos nombres extraños llenos de guiones bajos? ¿De donde vienen?

Cuando Python ve que deseas agregar una variable de instancia privada a un objeto y lo vas a hacer dentro de cualquiera de los métodos del mismo, hace los siguiente:

- Coloca un nombre de clase antes de su nombre.
- Añade además antes un guión bajo adicional.

El nombre es ahora totalmente accesible desde fuera de la clase. Para verlo puedes ejecutar un código como este:

print(ejemplo_objeto_1._Ejemplo__primero)
Salida:

1
Como ves obtienes un resultado válido y sin excepciones.  Como puedes ver, hacer que una propiedad sea privada es limitado. 

No funcionará si agregas una variable de instancia fuera de la clase. En este caso se comportará como cualquier otra propiedad normal.


2.- Variables de clase.

Una variable de clase es una propiedad que existe en una sola copia y se almacena fuera de cualquier objeto. Las variables de clase existen aunque no haya ningún objeto de esa clase.

Vamos a verlo con un ejemplo:

class Ejemplo:
    contador = 0
    def __init__(self, valor = 1):
        self.__primero = valor
        Ejemplo.contador += 1
        
objeto_1 = Ejemplo()
objeto_2 = Ejemplo(2)
objeto_3 = Ejemplo(4)

print(objeto_1.__dict__, objeto_1.contador)
print(objeto_2.__dict__, objeto_2.contador)
print(objeto_3.__dict__, objeto_3.contador)
Observa que en la primera línea de la clase se establece una variable llamada "contador" con un valor inicial de 0. Está variable se inicializa dentro de la clase pero fuera de cualquiera de sus métodos. Esto hace que esta variable sea una variable de clase.

El acceder a esta variable se realiza igual que con cualquier otro atributo de instancia. Esta en el cuerpo del constructor, como puedes ver, y lo que hace es incrementarse en uno cada vez que se crea un objeto. En efecto, la variable cuenta todos los objetos creados.

Salida:

{'_Ejemplo__primero': 1} 3
{'_Ejemplo__primero': 2} 3
{'_Ejemplo__primero': 4} 3
De la salida podemos inferir que:

  • Las variables de clase no se muestran en el diccionario de un objeto. Lo cual es lógico ya que no son parte del mismo.
  • Una variable de clase siempre presenta el mismo valor en todas las instancias de la clase (objetos)
Si convertimos una variable de clase en privada, tiene los mismo efectos que con un atributo de instancia. Vamos a verlo:

class Ejemplo:
    __contador = 0
    def __init__(self, valor = 1):
        self.__primero = valor
        Ejemplo.__contador += 1
        
objeto_1 = Ejemplo()
objeto_2 = Ejemplo(2)
objeto_3 = Ejemplo(4)

print(objeto_1.__dict__, objeto_1._Ejemplo__contador)
print(objeto_2.__dict__, objeto_2._Ejemplo__contador)
print(objeto_3.__dict__, objeto_3._Ejemplo__contador)

Salida:

{'_Ejemplo__primero': 1} 3
{'_Ejemplo__primero': 2} 3
{'_Ejemplo__primero': 4} 3
Antes hemos dicho que las variables de clase existen incluso aunque no se haya creado ninguna instancia de la clase (objeto). Vamos a aprovechar la oportunidad para mostrar la diferencia entre dos variables __dict__, la de la clase y la del objeto. Observa el siguiente código:

class Ejemplo:
    variable = 1
    def __init__(self, val):
        Ejemplo.variable = val
        
print(Ejemplo.__dict__)
objeto_1 = Ejemplo(5)

print(Ejemplo.__dict__)
print(objeto_1.__dict__)
Salida:

{'__module__': '__main__', 'variable': 1, '__init__': <function Ejemplo.__init__ at 0x7f8bb2157dc0>, '__dict__': <attribute '__dict__' of 'Ejemplo' objects>, '__weakref__': <attribute '__weakref__' of 'Ejemplo' objects>, '__doc__': None}
{'__module__': '__main__', 'variable': 5, '__init__': <function Ejemplo.__init__ at 0x7f8bb2157dc0>, '__dict__': <attribute '__dict__' of 'Ejemplo' objects>, '__weakref__': <attribute '__weakref__' of 'Ejemplo' objects>, '__doc__': None}
{}
Date cuenta que __dict__ del objeto está vacio, el objeto no tiene variables de instancia.

Comprobando la existencia de un atributo. 

En Python puede ocurrir que todos los objetos de una clase no tengan las mismas propiedades. Mira lo que ocurre en el siguiente ejemplo:

class Ejemplo:
    def __init__(self, valor):
        if valor % 2 != 0:
            self.a = 1
        else:
            self.b = 1

objeto_1 = Ejemplo(1)
print(objeto_1.a)
print(objeto_1.b)
Salida:

1
Traceback (most recent call last):
  File "main.py", line 10, in <module>
    print(objeto_1.b)
AttributeError: 'Ejemplo' object has no attribute 'b'
El objeto creado por el constructor solo puede tener uno de los dos atributos posibles,  a o b. Como puedes ver el intentar acceder a un atributo de clase no disponible provoca el error AttributeError.

La instrucción try - except te permite evitar problemas con propiedades inexistentes. Es sencillo mira el siguiente ejemplo:

class Ejemplo:
    def __init__(self, valor):
        if valor % 2 != 0:
            self.a = 1
        else:
            self.b = 1

objeto_1 = Ejemplo(1)
print(objeto_1.a)

try:
    print(objeto_1.b)
except AttributeError:
    pass
Salida:

1
Aunque esto no es muy elaborado y prácticamente lo que he hecho es ocultar el problema.

Afortunadamente Python proporciona una función que puede verificar con seguridad si algún objeto / clase contiene una propiedad específica. La función se llama hasattr y espera que se le pasen dos argumentos:

  • La clase o el objeto que se quiere verificar.
  • El nombre de la propiedad cuya existencia se quiere verificar. Hay que pasar el nombre en forma de cadena.
La función devuelve True o False.

Así es como se puede utilizar:

class Ejemplo:
    def __init__(self, valor):
        if valor % 2 != 0:
            self.a = 1
        else:
            self.b = 1

objeto_1 = Ejemplo(1)
print(objeto_1.a)

if hasattr(objeto_1, 'b'):
    print(objeto_1.b)
Salida:

1
No olvides que la función hasattr también puede operar con clases. Puedes usarla para saber si una variable de clase también está disponible. 

class Ejemplo:
    atributo = 1
    
if hasattr(Ejemplo, 'atributo'):
    print(Ejemplo.atributo)
Salida:
1

MÉTODOS.

¡Hagamos un resumen de lo que ya sabemos respecto a los métodos de una clase!

Como ya sabes el método es una función que está dentro de una clase.

Existe un requisito fundamental y es que ese método tiene que tener al menos un argumento o parámetro. Este generalmente se denomina "self" lo cual nos indica el propósito del parámetro que es identificar el objeto para el cual se invoca el método.

Cuando invoques al método, sin embargo no debes pasar este parámetro ya que Python lo hará por ti.

Por ejemplo:

class Clase:
    def metodo(self):
        print("método")

obj = Clase()
obj.metodo()
Salida:

método

Si deseas que el método tenga más parámetros, tienes que colocarlos después del self en la definición del método y pasarlos como argumentos durante la invocación sin especificar el self.

class Clase:
    def metodo(self, numero):
        print("método", numero)


obj = Clase()
obj.metodo(1)
obj.metodo(2)
obj.metodo(3)
Salida:

método 1
método 2
método 3
El párametro self es usado para tener acceso a las instancias del objeto y a las variables de clase.

class Clase:
    variable = 2
    def metodo(self):
        print(self.variable, self.valor)


obj = Clase()
obj.valor = 3
obj.metodo()
Salida:

2 3

El parámetro self también se usa para invocar otros métodos dentro de la clase.

class Clase:
    def otro(self):
        print("otro")
    def metodo(self):
        print("método")
        self.otro()

obj = Clase()
obj.metodo()
Salida:

método
otro
Si como nombre del método se utiliza __init__, no será un método normal, será un constructor y se invocará de forma automática cuando se instancie el objeto de la clase.

El constructor:
  • Está obligado a tener el parámetro self.
  • Pudiera o no tener más parámetros que solo self. 
  • Se puede utilizar para configurar el objeto, es decir inicializa su estado interno, crea las variables de instancia, crea instancias de cualquier otro objeto si fuera necesario.
Ten en cuenta que el constructor:
  • no puede retornar un valor.
  • no se puede invocar directamente desde el objeto o desde dentro de la clase (si que puedes invocar un constructor de una superclase como veremos más adelante)
Como __init__ es un método, y un método es una función, podemos usar los mismos trucos que con las funciones ordinarias.

Por ejemplo podemos definir un constructor con un valor de argumento predeterminado.

class Clase:
    def __init__(self, valor = None):
        self.var = valor

objeto_1 = Clase('objeto')
objeto_2 = Clase()

print(objeto_1.var)
print(objeto_2.var)
Salida:

objeto
None
Todo lo que hemos dicho sobre el manejo de nombres también se aplica a los nombres de los métodos. Por ejemplo un método cuyo nombre empieza con __ está parcialmente oculto.

class Clase:
    def visible(self):
        print("visible")
    def __oculto(self):
        print("oculto")

objeto_1 = Clase()

print(objeto_1.visible())

try: 
    objeto_1.__oculto()
except:
    print("fallo!")

objeto_1._Clase__oculto() 
Salida:

visible
fallo!
oculto


La vida interior de las clases y objetos.


Cada clase de Python y cada objeto de Python está pre-equipado con un conjunto de atributos útiles que pueden utilizarse para examinar sus utilidades.

Ya conoces uno de estos que es la propiedad __dict__.

class Clase:
    variable = 1
    def __init__(self):
        self.var = 2
        
    def metodo(self):
        pass
    
    def __oculto(self):
        pass

objeto_1 = Clase()

print(objeto_1.__dict__)
print(Clase.__dict__)
Salida:

{'var': 2}
{'__module__': '__main__', 'variable': 1, 
'__init__': <function Clase.__init__ at 0x7f992083bdc0>, 
'metodo': <function Clase.metodo at 0x7f992083be50>, 
'_Clase__oculto': <function Clase.__oculto at 0x7f992083bee0>, 
'__dict__': <attribute '__dict__' of 'Clase' objects>, 
'__weakref__': <attribute '__weakref__' of 'Clase' objects>, 
'__doc__': None}__dict__ es un diccionario. 

Otra propiedad incorporada que merece la pena ver es __name__. La propiedad contiene el nombre de la clase. Esta propiedad no funciona en los objetos, solo en las clases.

Si deseas encontrar la clase de un objeto en particular puedes usar una función llamada type() la cual es capaz de mostrarnos el nombre de la clase que ha instanciado un objeto. 

Puedes verlo en el siguiente código:

class Clase:
    pass

print(Clase.__name__)
objeto = Clase()
print(type(objeto))
print(f"La clase del objeto se llama {type(objeto).__name__}")
Salida:

Clase
<class '__main__.Clase'>
La clase del objeto se llama Clase
__module__ también es una cadena. almacena el nombre del modulo que contiene la definición de una clase.

class Clase:
    pass

print(Clase.__module__)
objeto = Clase()
print(objeto.__module__)
Salida:

__main__
__main__
Como sabes, cualquier modulo llamado __main__ en realidad no es un módulo, sino que es el archivo actualmente en ejecución.

__bases__ es una tupla. La tupla contiene clases, no nombre de clases, que son superclases directas de la misma. El orden en el que se muestran es el mismo que el utilizado dentro de la definición de clase. Solo las clases tienen este atributo, los objetos no.

Esto nos va a servir para resaltar como funciona la herencia.

class Padre1:
    pass
        
class Padre2:
    pass
        
class Hija(Padre1, Padre2):
    pass

def printBases(cls):
    print("( ", end='')
    for x in cls.__bases__:
        print(x.__name__, end=' ')
    print(')')    
    
printBases(Padre1)
printBases(Padre2)
printBases(Hija)
Salida:

( object )
( object )
( Padre1 Padre2 )
Visto lo anterior la pregunta es ¿Qué podemos descubrir acerca de las clases en Python? La respuesta es: todo.

Analiza el siguiente código e intenta adivinar como funciona.

class Ejemplo:
    pass

obj = Ejemplo()
obj.a = 1
obj.b = 2
obj.i = 3
obj.irreal = 3.5
obj.integer = 4
obj.z = 5

def caracteristicas(obj):
    for name in obj.__dict__.keys():
        if name.startswith('i'):
            # getattr obtiene el valor de la instancia del objeto
            val = getattr(obj, name)
            # Si el valor de la instancia es un numero entero
            if isinstance(val, int):
                # Incrementamos el valor en 1
                setattr(obj, name, val + 1)

print(obj.__dict__)
caracteristicas(obj)
print(obj.__dict__)
Salida:

{'a': 1, 'b': 2, 'i': 3, 'irreal': 3.5, 'integer': 4, 'z': 5}
{'a': 1, 'b': 2, 'i': 4, 'irreal': 3.5, 'integer': 5, 'z': 5}

HERENCIA DE CLASES ¿POR QUE Y COMO?


Antes de comenzar a hablar sobre la herencia, vamos a ver como las clases se pueden presentar a si mismas. Comencemos con un ejemplo:

class Estrella:
    def __init__(self, estrella, galaxia):
        self.nombre = estrella
        self.galaxia = galaxia
        
sol = Estrella('Sol', 'Via Lactea')
print(sol)
 El programa imprime una única línea de texto que en mi caso es:

<__main__.Estrella object at 0x7fd8946a0f10>

Si ejecutas este mismo en tu ordenador, verás algo similar aunque el número hexadecimal (la subcadena que comienza por 0x) será diferente. Como puedes ver la impresión del objeto no nos proporciona una información extremadamente útil. 

Existe algo mejor. Cuando Python necesita que alguna clase u objeto deba ser presentado como una cadena (es recomendable usar el objeto como argumento de la función print), intenta evocar un método llamado __str__ del objeto y emplear la cadena que devuelve. 

El método __str__ que viene por defecto devuelve esa cadena tan fea que vimos en el ejemplo anterior. Sin embargo podemos cambiarlo definiendo nuestro propio método __str__. Vamos a hacerlo. Observa de nuevo el código:

class Estrella:
    def __init__(self, estrella, galaxia):
        self.nombre = estrella
        self.galaxia = galaxia
    
    def __str__(self):
        return self.nombre + ' en ' + self.galaxia
        
sol = Estrella('Sol', 'Via Lactea')
print(sol)
Salida:

Sol en Via Lactea
El nuevo método __str__ genera una cadena que consiste en el nombre de la estrella y de la galaxia con lo cual se ve bastante mejor que el método original. 

La herencia es una práctica común (en la programación de objetos) de pasar atributos y métodos de la superclase (definida y existente) a una clase recién creada, llamada subclase.

En otras palabras, la herencia es una forma de construir una nueva clase, no desde cero, sino utilizando un repertorio de rasgos ya definido. La nueva clase hereda (y esta es la clave) todo el equipamiento ya existente, pero puedes agregar algo nuevo si es necesario.

Gracias a eso, es posible construir clases más especializadas (más concretas) utilizando algunos conjuntos de reglas y comportamientos generales predefinidos.

El factor más importante del proceso es la relación entre la superclase o clase Padre y todas sus subclases ( Si B es una subclase de A y C es una subclase de B, esto tambien significa que C es una subclase de A). Vamos a verlo con un ejemplo.

class Vehiculo:
    pass

class Vehiculo_terrestre(Vehiculo):
    pass

class Coche(Vehiculo_terrestre):
    pass
De momento todas las clases están vacías.  La clase Vehículo es una superclase para Vehiculo_terrestre y Coche. La clase Vehiculo_terrestre es una subclase de Vehiculo y una superclase de Coche al mismo tiempo. La clase Coche es una subclase de Vehiculo y Vehiculo_terrestre al mismo tiempo.

Esta relación la sabemos porque estamos viendo el código pero sino ¿Podríamos averiguarlo?

issubclass()

Python nos ofrece una función que es capaz de identificar una relación entre dos clases. Puede verificar si una clase es particular es subclase de cualquier otra.

Así es como se utilizaría:

issubclass(clase_1, clase_2)

La función devuelve True si clase_1 es una subclase de clase_2 y False en caso contrario.

Vamos a ver como funciona:

class Vehiculo:
    pass

class Vehiculo_terrestre(Vehiculo):
    pass

class Coche(Vehiculo_terrestre):
    pass

for clase_1 in [Vehiculo, Vehiculo_terrestre, Coche]:
    for clase_2 in [Vehiculo, Vehiculo_terrestre, Coche]:
        print(clase_1.__name__+' - ' + clase_2.__name__,issubclass(clase_1, clase_2), end='  ')
    print()

Salida:

Vehiculo - Vehiculo True  Vehiculo - Vehiculo_terrestre False  Vehiculo - Coche False  
Vehiculo_terrestre - Vehiculo True  Vehiculo_terrestre - Vehiculo_terrestre True  Vehiculo_terrestre - Coche False  
Coche - Vehiculo True  Coche - Vehiculo_terrestre True  Coche - Coche True  

isinstance()


Devuelve True si el objeto es una instancia de la clase o False en caso contrario.

Su estructura es:

isinstance(nombre_objeto, nombre_clase)
Ser instancia de una clase significa que el objeto se ha preparado utilizando las propiedades y métodos contenidos en una clase o en una superclase.

Recuerda: si una subclase contiene al menos las mismas características que cualquiera de sus superclases, significa que los objetos de la subclase pueden hacer los mismo que los objetos derivados de la superclase, por tanto, es una instancia de su clase de inicio y de cualquiera de sus superclases.


class Vehiculo:
    pass

class Vehiculo_terrestre(Vehiculo):
    pass

class Coche(Vehiculo_terrestre):
    pass

mi_vehiculo = Vehiculo()
mi_vehiculo_terrestre = Vehiculo_terrestre()
mi_coche = Coche()

for obj in [mi_vehiculo, mi_vehiculo_terrestre, mi_coche]:
    for clase in [Vehiculo, Vehiculo_terrestre, Coche]:
        print(isinstance(obj, clase), end="\t")
    print()   
Salida:

True	False	False	
True	True	False	
True	True	True	

Hagamos que el resultado sea más legible

↓ es una instancia de →VehiculoVehiculo_terrestreCoche
mi_vehiculoTrueFalseFalse
mi_vehiculo_terrestreTrueTrueFalse
mi_cocheTrueTrueTrue


El operador is.


El operador is verifica si dos variables apuntan al mismo objeto. No olvides que las variables no guardan los objetos en si mismas, sino solo los identificadores que apuntan a la memoria interna de Python.

Asignar un valor de una variable de objeto a otra variable no copia el objeto, lo cual puede ser útil en ciertas circunstancias. 

class Ejemplo:
    def __init__(self, valor):
        self.valor = valor
        
objeto_1 = Ejemplo(0)
objeto_2 = Ejemplo(2)
objeto_3 = objeto_1

print(objeto_1 is objeto_2)
print(objeto_2 is objeto_3)
print(objeto_3 is objeto_1)
print(objeto_1.valor, objeto_2.valor, objeto_3.valor)

cadena_1 = "Maria tenía un "
cadena_2 = "Maria tenía un corderito"
cadena_1 += "corderito"
print(cadena_1==cadena_2, cadena_1 is cadena_2)
Salida:

False
False
True
0 2 0
True False
Los resultados prueban que objeto_1 y objeto_3 son en realidad los mismos objetos, mientras que cadena_1 y cadena_2 no lo son a pesar de que su contenido sea el mismo.


¿Cómo encuentra Python las propiedades y los métodos?


Observa este código:

class Padre:
    def __init__(self, nombre):
        self.nombre = nombre
    def __str__(self):
        return "Mi nombre es " + self.nombre + "."
    
class Hija(Padre):
    def __init__(self, nombre):
        Padre.__init__(self, nombre)
        
obj = Hija('Alberto')
print(obj)
Vamos a analizarlo:

  • Existe una clase llamada Padre, que define su propio constructor para asignar la propiedad al objeto, llamada "nombre".
  • Las clase también define el método __str__(), lo que permite que la clase pueda presentar su identidad en forma de texto.
  • La clase se usa luego para crear una subclase llamada Hija. La clase Hija define su propio constructor, que invoca el de la superclase o clase Padre. Padre.__init__(self, nombre)
  • Hemos instanciado un objeto de la clase Hija y lo hemos impreso.
El código da como salida:

Mi nombre es Alberto.

Mira de nuevo el código, lo hemos modificado para ver otra forma de acceder a cualquier entidad definida dentro de la clase Padre o Superclase.

class Padre:
    def __init__(self, nombre):
        self.nombre = nombre
    def __str__(self):
        return "Mi nombre es " + self.nombre + "."
    
class Hija(Padre):
    def __init__(self, nombre):
        super().__init__(nombre)
        
obj = Hija('Alberto')
print(obj)
En este caso hacemos uso de la función super(), que accede a la superclase sin necesidad de saber su nombre. La función super() crea un contexto en el que no tiene que (además no debe) pasar el argumento propio al método que se invoca. Es por eso que es posible activar el constructor de la superclase utilizando solo un argumento.

Se puede usar este mecanismo no solo para invocar el constructor de la superclase, sino para obtener acceso a cualquiera de los recursos disponibles dentro de la superclase.

Intentemos hacer algo similar pero con propiedades, más concretamente con variables de clase.

class Padre:
    supVar = 1
    
class Hija(Padre):
    subVar = 2
        
obj = Hija()
print(obj.supVar)
print(obj.subVar)
Salida:

1
2
Lo mismo se puede observar con las variables de instancia.

class Padre:
    def __init__(self):
        self.supVar = 1
    
class Hija(Padre):
    def __init__(self):
        super().__init__()
        self.subVar = 2
        
obj = Hija()
print(obj.supVar)
print(obj.subVar)
Salida:

1
2
Ahora es posible realizar una formulación general de como trata Python las herencias.

Cuando intentes acceder a una entidad de cualquier objeto, Python intentará en este orden:

  • Encontrarlas dentro del objeto mismo.
  • Encontrarlas en todas las clases involucradas en la línea de herencia del objeto de abajo hacia arriba.
Si ambos fallan se generará una excepción (AtributeError). 

Vamos a analizar un poco más en detalle la primera condición. Todos los objetos derivados de una clase en particular pueden tener diferentes conjuntos de atributos y algunos de ellos pueden añadirse bastante despúes de instanciarse el objeto.

El ejemplo anterior refleja esto mediante una línea de herencia a tres niveles. Míralo con detenimiento:

class Level1:
    variable_1 = 100
    def __init__(self):
        self.var_1 = 101

    def fun_1(self):
        return 102


class Level2(Level1):
    variable_2 = 200
    def __init__(self):
        super().__init__()
        self.var_2 = 201
    
    def fun_2(self):
        return 202


class Level3(Level2):
    variable_3 = 300
    def __init__(self):
        super().__init__()
        self.var_3 = 301

    def fun_3(self):
        return 302


obj = Level3()

print(obj.variable_1, obj.var_1, obj.fun_1())
print(obj.variable_2, obj.var_2, obj.fun_2())
print(obj.variable_3, obj.var_3, obj.fun_3())
Salida:

100 101 102
200 201 202
300 301 302
Todo lo visto anteriormente hace referencia a casos de herencia única, cuando una subclase tiene exactamente una superclase. Esta es la situación más común y también la más recomendada. 

Sin embargo Python también soporta la herencia multiple. Esta ocurre cuando una clase tiene más de una superclase. Vamos a verlo con un ejemplo.

class SuperA:
    var_a = 10
    def fun_a(self):
        return 11
class SuperB:
    var_b = 20
    def fun_b(self):
        return 21

class Sub(SuperA, SuperB):
    pass

obj = Sub()
print(obj.var_a, obj.fun_a())
print(obj.var_b, obj.fun_b())
La clase Sub tiene dos superclases SuperA y SuperB. Esto significa que la clase Sub hereda todas las propiedades y métodos de ambas.

Salida:

10 11
20 21
Ahora es el momento de introducir un nuevo término como es el overriding (anulación).

¿Qué crees que sucederá si alguna de las superclases tiene algo, un método o una propiedad definida con el mismo nombre?

Vamos a ver un ejemplo:

class Level1:
    var = 100
    def fun(self):
        return 101


class Level2(Level1):
    var = 200
    def fun(self):
        return 201


class Level3(Level2):
    pass


obj = Level3()

print(obj.var, obj.fun())

Como ves tanto la clase Level1 como Level 2 tienen una variable de clase y un método que se llaman igual. ¿Significará esto que el objeto instanciado podrá acceder por herencia a cualquiera de ambas? La respuesta en NO.

Salida:

200 201

La entidad definida en último lugar anula la misma entidad definida previamente. Esta característica se puede usar intencionadamente para modificar el comportamiento predeterminado de las clases cuando cualquiera de tus clases necesite actuar de manera diferente a su ancestro.

También podemos decir que Python busca una entidad de abajo hasta arriba y se detiene en la primera que encuentra. 

Pero ¿Que ocurre cuando una clase hereda de dos ancestros que le ofrecen la misma entidad y están al mismo nivel?

A ver si me explico con un ejemplo.

class Izquierda:
    var = 100
    def fun(self):
        return 101


class Derecha:
    var = 200
    def fun(self):
        return 201


class Level3(Izquierda, Derecha):
    pass


obj = Level3()

print(obj.var, obj.fun())
Salida:

100 101

Podemos ver que Python tiene unas reglas para buscar las cosas:

  • Primero busca dentro del objeto mismo.
  • Luego en sus superclases de abajo hacia arriba.
  • Finalmente si hay más de una clase en la ruta de herencia, de izquierda a derecha.

martes, 11 de enero de 2022

Tratamiento de las Excepciones en Python


imagen de código en python para tratar excepciones.







Excepciones.

Por definición las excepciones son errores que se producen durante la ejecución del código y que no son esperadas por el usuario.

Un ejemplo típico es el tener un programa que divide dos números y tratamos de dividir uno de ellos entre cero.

> print(8/0)
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ZeroDivisionError: division by zero

Para gestionarlas y que no finalicen de forma inesperada un programa, podemos usar las siguientes instrucciones de Python:


try: 
    [instrucciones o código a ejecutar.]
except <tipo de excepción>, <tipo de excepción>...:
    [instrucciones o código a ejecutar si ocurre la excepción señalada. En 
    el caso de que no se indique una excepción en concreto y se deje en
    blanco, la excepción se ejecutará con cualquier tipo de error. También
    se pueden poner varios except seguidos y saltará el código del error 
    correspondiente.]
except: # Si ponemos excepciones especificas, está última debe ser la 
    genérica para recoger las que no se recojan previamente. La genérica
    SIEMPRE DEBE SER LA ÚLTIMA en colocarse. 
else: # esta instrucción es opcional
    [código a ejecutar solamente si no ocurre ninguna excepción.]
finally: # este instrucción es opcional
    [código a ejecutar tanto si ocurren excepciones como si no.]

Un ejemplo. Si creamos una función que muestre el resultado de dividir un número entre otro:

def division(numero_1, numero_2):
    try:
        print(numero_1/numero_2)
    except ZeroDivisionError:
        print("No puedes dividir un número entre cero.")
        return "operación errónea"
    else:
        print("División realizada correctamente.")
    finally:
        return "Función terminada."

y le pasamos un resultado correcto por ejemplo dividir 8 entre 2:

print(division(8,4))
obtendremos el siguiente resultado:

2.0
División realizada correctamente.
Función terminada.
Si por el contrario intentamos dividir entre cero

print(division(8,0))
la excepción saltará y nos mostrará el siguiente resultado:

No puedes dividir un número entre cero.
Función terminada.

También podemos poner "finally" sin los "except" previos que capturan el error. En este caso primero se ejecutaría el código que hubiésemos definido dentro del "finally" y luego el programa mostraría el error. Si prescindiéramos del "finally", entonces el error impide que siga el programa. 

Por ejemplo:

try:
    print(8/0)
finally:
    print("Ha habido un error")
Ha habido un error
Traceback (most recent call last):
  File "<string>", line 2, in <module>
ZeroDivisionError: division by zero
> 
Como ves se muestra el mensaje, aunque previamente se ha producido un error en el programa.

Excepciones propias - RAISE -


La instrucción "raise" en Python permite lanzar un mensaje de error con un texto personalizado. Se suele poner después de la instrucción "except", aunque también lo puedes poner donde quieras para llamar al error.

La síntesis para lanzar el error sería:

raise TipodeError("Texto explicativo del error") 

Siguiendo con el ejemplo que hemos usado hasta ahora

# Vamos a dividir 8 entre un número pero no queremos que se pueda
# utilizar el cero como divisor.
divisor = int(input("Introduce un numero como divisor: "))
if divisor == 0:
    raise ZeroDivisionError("¡No se puede dividir entre cero!")
        
print(8/divisor)
Introduce un numero como divisor: 0
Traceback (most recent call last):
  File "<string>", line 5, in <module>
ZeroDivisionError: ¡No se puede dividir entre cero!
> 


La instrucción assert.


Aunque en si misma no sirve para detectar tratar directamente excepciones si que nos va a servir para detectar errores de código y para testear nuestro programa. La estructura es:

assert condición, "texto a mostrar"


Imaginemos que creamos una función que solo calcula la raíz cuadrada de un número. La raíz cuadrada de un numero es ese mismo número elevado a 1/2 o 0.5. Para saber si la hemos construido bien sabemos que la raíz cuadrada de 25 es 5.0 por tanto podemos probar la función usando assert de la siguiente manera para ver si la función funciona correctamente:

def raiz_cuadrada(numero):
    return pow(numero, 0.5)
    
resultado = raiz_cuadrada(25)
assert resultado == 5.0, "La función no funciona."
print("el calculo es correcto")
Si la condición es True no hace nada y el programa continua normalmente, pero por el contrario si la condición es False lanza un error "AssertionError" y detiene el programa. Si ejecutamos el código tal como está:


 el calculo es correcto
>
Si por ejemplo ahora cambiamos el exponente por que nos hemos confundido al teclear y ponemos 0.6 y volvemos a ejecutar el programa:

def raiz_cuadrada(numero):
    return pow(numero, 0.6)
    
resultado = raiz_cuadrada(25)
assert resultado == 5.0, "La función no funciona"

print("el calculo es correcto")
Traceback (most recent call last):
  File "<string>", line 5, in <module>
AssertionError: La función no funciona
Vemos que nos muestra que la función no funciona como debería y nos funciona una pista para ver donde esta el fallo y poder depurarlo.

Si ya has visto el tema de la programación orientada a objetos de Python puedes seguir leyendo esta ampliación sobre las excepciones.

Las excepciones son Clases.


Los ejemplos anteriores se centraron en detectar un tipo específico de excepción y responder de manera apropiada. Ahora vamos a profundizar más y mirar dentro de la excepción misma.

Probablemente no te sorprenderá saber que las excepciones son clases. Además, cuando se genera una excepción, se crea una instancia de un objeto de la clase y pasa por todos los niveles de ejecución del programa, buscando el bloque "except" que está preparado para tratar con la excepción.

Tal objeto lleva información útil que puede ayudarte a identificar con precisión todos los aspectos de la situación pendiente. Para lograr ese objetivo, Python ofrece una variante especial de la cláusula de excepción: puedes encontrarla en el siguiente código.

try:
    int("Hola")
except Exception as e:
    print(e)
    print(e.__str__())

Salida:

invalid literal for int() with base 10: 'Hola'
invalid literal for int() with base 10: 'Hola'


Como puedes ver, la sentencia except se extendió y contiene una frase adicional que comienza con la palabra clave reservada as, seguida por un identificador. El identificador está diseñado para capturar la excepción con el fin de analizar su naturaleza y sacar conclusiones adecuadas.

Nota: el alcance del identificador solo es dentro del except, y no va más allá.

El ejemplo presenta una forma muy simple de utilizar el objeto recibido: simplemente imprímelo (como puedes ver, la salida es producida por el método del objeto __str__()) y contiene un breve mensaje que describe la razón.

Se imprimirá el mismo mensaje si no hay un bloque except en el código, y Python se verá obligado a manejarlo por sí mismo.

Todas las excepciones de Python forman una jerarquia de clases. Si quieres puedes verlas sin problemas. Observa el código.

def imprimir_arbol_excepciones(estaclase, anidado = 0):
    if anidado > 1:
        print("   |" * (anidado - 1), end="")
    if anidado > 0:
        print("   +---", end="")
    
    print(estaclase.__name__)
    for subclass in estaclase.__subclasses__():
        imprimir_arbol_excepciones(subclass, anidado + 1)
        
imprimir_arbol_excepciones(BaseException)  
Salida:

BaseException
   +---Exception
   |   +---TypeError
   |   +---StopAsyncIteration
   |   +---StopIteration
   |   +---ImportError
   |   |   +---ModuleNotFoundError
   |   |   +---ZipImportError
   |   +---OSError
   |   |   +---ConnectionError
   |   |   |   +---BrokenPipeError
   |   |   |   +---ConnectionAbortedError
   |   |   |   +---ConnectionRefusedError
   |   |   |   +---ConnectionResetError
   |   |   +---BlockingIOError
   |   |   +---ChildProcessError
   |   |   +---FileExistsError
   |   |   +---FileNotFoundError
   |   |   +---IsADirectoryError
   |   |   +---NotADirectoryError
   |   |   +---InterruptedError
   |   |   +---PermissionError
   |   |   +---ProcessLookupError
   |   |   +---TimeoutError
   |   |   +---UnsupportedOperation
   |   |   +---ItimerError
   |   +---EOFError
   |   +---RuntimeError
   |   |   +---RecursionError
   |   |   +---NotImplementedError
   |   |   +---_DeadlockError
   |   +---NameError
   |   |   +---UnboundLocalError
   |   +---AttributeError
   |   +---SyntaxError
   |   |   +---IndentationError
   |   |   |   +---TabError
   |   +---LookupError
   |   |   +---IndexError
   |   |   +---KeyError
   |   |   +---CodecRegistryError
   |   +---ValueError
   |   |   +---UnicodeError
   |   |   |   +---UnicodeEncodeError
   |   |   |   +---UnicodeDecodeError
   |   |   |   +---UnicodeTranslateError
   |   |   +---UnsupportedOperation
   |   +---AssertionError
   |   +---ArithmeticError
   |   |   +---FloatingPointError
   |   |   +---OverflowError
   |   |   +---ZeroDivisionError
   |   +---SystemError
   |   |   +---CodecRegistryError
   |   +---ReferenceError
   |   +---MemoryError
   |   +---BufferError
   |   +---Warning
   |   |   +---UserWarning
   |   |   +---DeprecationWarning
   |   |   +---PendingDeprecationWarning
   |   |   +---SyntaxWarning
   |   |   +---RuntimeWarning
   |   |   +---FutureWarning
   |   |   +---ImportWarning
   |   |   +---UnicodeWarning
   |   |   +---BytesWarning
   |   |   +---ResourceWarning
   +---GeneratorExit
   +---SystemExit
   +---KeyboardInterrupt
Este programa muestra todas las excepciones predefinidas en forma de árbol.

Anatomía detallada de las excepciones.


La clase BaseException introduce una propiedad llamada args. Es una tupla diseñada para reunir todos los argumentos pasados al constructor de la clase. Está vacío si la construcción se ha invocado sin ningún argumento, o solo contiene un elemento cuando el constructor recibe un argumento y así sucesivamente (no se considera argumento aquí el self)

Vamos a ver un poco esto a través del siguiente código.

def print_args(args):
    lng = len(args)
    if lng == 0:
        print("")
    elif lng == 1:
        print(args[0])
    else:
        print(str(args))

try:
    raise Exception
except Exception as e:
    print(e, e.__str__(), sep = " : ", end = " : ")
    print_args(e.args)
    
try:
    raise Exception("Mi excepción")
except Exception as e:
    print(e, e.__str__(), sep = " : ", end = " : ")
    print_args(e.args)
    
try:
    raise Exception("Mi", "excepción")
except Exception as e:
    print(e, e.__str__(), sep = " : ", end = " : ")
    print_args(e.args)
Hemos utilizado la función para imprimir el contenido de la propiedad args en tres casos diferentes, donde la excepción de la clase Exception es generada de tres maneras distintas. Para hacerlo más espectacular, también hemos impreso el objeto en sí, junto con el resultado de la invocación __str__().

El primer caso parece de rutina, solo hay el nombre Exception después de la palabra clave reservada raise. Esto significa que el objeto de esta clase se ha creado de la manera más rutinaria.

El segundo y el tercer caso pueden parecer un poco extraños a primera vista, pero no hay nada extraño, son solo las invocaciones del constructor. En la segunda sentencia raise, el constructor se invoca con un argumento, y en el tercero, con dos.

Como puedes ver, la salida del programa refleja esto, mostrando los contenidos apropiados de la propiedad args:

 :  : 
Mi excepción : Mi excepción : Mi excepción
('Mi', 'excepción') : ('Mi', 'excepción') : ('Mi', 'excepción')

¿Cómo crear tu propia excepción?

La jerarquía de excepciones no está cerrada ni terminada, y siempre puedes ampliarla si deseas o necesitas crear tu propio mundo poblado con tus propias excepciones.

Puede ser útil cuando se crea un módulo complejo que detecta errores y genera excepciones, y deseas que las excepciones se distingan fácilmente de cualquier otra de Python.

Esto se puede hacer al definir tus propias excepciones como subclases derivadas de las predefinidas.

Nota: si deseas crear una excepción que se utilizará como un caso especializado de cualquier excepción incorporada, derívala solo de esta. Si deseas construir tu propia jerarquía, y no quieres que esté estrechamente conectada al árbol de excepciones de Python, derívala de cualquiera de las clases de excepción principales, tal como: Exception.

Imagina que has creado una aritmética completamente nueva, regida por sus propias leyes y teoremas. Está claro que la división también se ha redefinido, y tiene que comportarse de una manera diferente a la división de rutina. También está claro que esta nueva división debería plantear su propia excepción, diferente de la incorporada ZeroDivisionError, pero es razonable suponer que, en algunas circunstancias, tu (o el usuario de tu aritmética) pueden tratar todas las divisiones entre cero de la misma manera.

Demandas como estas pueden cumplirse en la forma presentada en siguiente código.

class MiDivisionEntreCeroError(ZeroDivisionError):
    pass

def haz_la_division(mine):
    if mine:
        raise MiDivisionEntreCeroError("Peores Noticias")
    else:
        raise ZeroDivisionError("Malas Noticias")
        
for modo in [False, True]:
    try:
        haz_la_division(modo)
    except ZeroDivisionError:
        print('División entre cero')
        
for modo in [False, True]:
    try:
        haz_la_division(modo)
    except MiDivisionEntreCeroError:
        print('Mi división entre cero')
    except ZeroDivisionError:
        print('División entre cero original')

Salida:

División entre cero
División entre cero
División entre cero original
Mi división entre cero
Mira el código y analicémoslo:

Hemos definido nuestra propia excepción, llamada MiDivisionEntreCeroError , derivada de la incorporada ZeroDivisionError. Como puedes ver, hemos decidido no agregar ningún componente nuevo a la clase.

En efecto, una excepción de esta clase puede ser, dependiendo del punto de vista deseado, tratada como una simple excepción ZeroDivisionError, o puede ser considerada por separado.

La función haz_la_division() genera una excepción MiDivisionEntreCeroError o ZeroDivisionError dependiendo del valor del argumento.

La función se invoca cuatro veces en total, mientras que las dos primeras invocaciones se manejan utilizando solo un bloque except (la más general), las dos últimas invocan dos bloques diferentes, capaces de distinguir las excepciones (no lo olvides: el orden de los bloques hace una diferencia fundamental).

Vamo ahora a crear un sistema de excepciones que no tienen nada que ver con las que vienen por defecto en Python. Imagina que estás trabajando en un sistema para simular una pizzeria. Sería conveniente crear un sistema de excepciones por separado a las de Python. 

Podemos empezar a construirla definiendo una excepción general como una nueva clase base para cualquier otra excepción generalizada. 

class PizzaError(Exception):
    def __init__(self, pizza, mensaje):
        Exception.__init__(self, mensaje)
        self.pizza = pizza
Vamos a recopilar mas información de la que recoge normalmente una Exception, por ello le pasamos dos argumentos:

- Uno que especifica una pizza determinada como tema del proceso.
- Otro que contiene una descripción más o menos precisa del problema.

El segundo parámetro se lo pasamos al constructor de la superclase y guardamos el primero dentro de nuestra propiedad.

Un problema más especifico en una pizza como puede ser un exceso de queso puede requerir una Excepción más especifica:

class DemasiadoQueso(PizzaError):
    def __init__(self, pizza, queso, mensaje)
    super().__init__(pizza, mensaje)
    self.queso = queso

La excepción DemasiadoQueso necesita más información que la excepción regular PizzaError.  Por esto lo agregamos al constructor y guardamos el nombre queso para su uso posterior.

Vamos a combinar las dos excepciones anteriores en un pequeño ejemplo:

class PizzaError(Exception):
    def __init__(self, pizza, mensaje):
        Exception.__init__(self, mensaje)
        self.pizza = pizza
        
class DemasiadoQueso(PizzaError):
    def __init__(self, pizza, queso, mensaje):
        super().__init__(pizza, mensaje)
        self.queso = queso
    
def haciendo_pizza(pizza, queso):
    if pizza not in ['margarita','romana', 'calzone']:
        raise PizzaError(pizza, "No existe tal pizza en el menú.")
    if queso > 100:
        raise DemasiadoQueso(pizza, queso, "Demasiado queso en la pizza.")
    print("¡La pizza está lista!.")
        
for (pz, qs) in [('calzone', 10), ('margarita', 110), ('cazurra',20)]:
    try:
        haciendo_pizza(pz, qs)
    except DemasiadoQueso as dmqs:
        print(dmqs, ':', dmqs.queso)
    except PizzaError as pe:
        print(pe, ':', pe.pizza)
Salida:

¡La pizza está lista!.
Demasiado queso en la pizza. : 110
No existe tal pizza en el menú. : cazurra

Una de las excepciones es generada dentro de la función haciendo_pizza() cuando ocurra alguna de estas situaciones erróneas:

- Una pizza con demasiado queso.
- Una pizza que no está en la carta.

Nota:
  • Si removemos el bloque except DemasiadoQueso, todas las excepciones que se produzcan serán PizzaError.
  • El remover el bloque que comienza con except PizzaError provocará que la excepción DemasiadoQueso no pueda ser manejada, y hará que el programa finalize.
Aunque la solución anterior funciona se necesita pasar los argumentos requeridos o sino las excepciones no funcionan. Esto se puede solucionar estableciendo valores predeterminados para todos los parámetros del constructor.

La solución sería:

class PizzaError(Exception):
    def __init__(self, pizza = 'desconocida', mensaje = ''):
        Exception.__init__(self, mensaje)
        self.pizza = pizza
        
class DemasiadoQueso(PizzaError):
    def __init__(self, pizza = 'desconocida', queso = '>100', mensaje = ''):
        super().__init__(pizza, mensaje)
        self.queso = queso
    
def haciendo_pizza(pizza, queso):
    if pizza not in ['margarita','romana', 'calzone']:
        raise PizzaError(pizza, "No existe tal pizza en el menú.")
    if queso > 100:
        raise DemasiadoQueso(pizza, queso, "Demasiado queso en la pizza.")
    print("¡La pizza está lista!.")
        
for (pz, qs) in [('calzone', 10), ('margarita', 110), ('cazurra',20)]:
    try:
        haciendo_pizza(pz, qs)
    except DemasiadoQueso as dmqs:
        print(dmqs, ':', dmqs.queso)
    except PizzaError as pe:
        print(pe, ':', pe.pizza)









miércoles, 22 de diciembre de 2021

Los Generadores en Python

 

imagen de una señal que pone yield


Generadores en Python: codificación y ventajas.

Se componen de tres elementos:

  1. La instrucción yield
  2. Un bucle para recorrerlos.
  3. Una variable que actúa como contenedor.

Un generador en Python se parece mucho a una función normal solamente que se sustituye la palabra reservada 'return' por 'yield'. Estas funciones generadoras no tienen los datos guardados en ningún sitio sino que se van generando a medida que se solicitan.

Vamos a verlo con un ejemplo.

Creemos dos funciones aparentemente muy parecidas y las ejecutamos.

def funcion():
    return 'hola'

def generador():
    yield 'hola'
    
print(funcion())
print(generador())
hola
<generator object generador at 0x7fe7f1d5fac0>

Como ves la primera función devuelve el valor esperado 'hola' sin embargo la segunda lo que devuelve en realidad es un objeto de la clase generador.

Principales Características de los GENERADORES:

- Son estructuras que extraen valores de una función y que se almacenan en objetos iterables, que se pueden recorrer con un bucle for.

- Estos valores se almacenan de uno en uno.

- Cada vez que un generador almacena un valor, este permanece en estado pausado hasta que se solicita el siguiente valor, es lo que se conoce como suspensión de estado.

Debido al hecho de que producen un elemento cada vez, los generadores no tienen restricciones de memoria, pueden ser infinitos.

Lo mejor es que lo veamos con varios ejemplos. Imaginemos que queremos crear un generador que nos vaya devolviendo números desde el número 1 hasta un determinado número que le digamos de uno en uno. 

Observa el siguiente código:

> def genera_numeros(maximo):
>    n=1
>    while n<maximo:
>        yield n
>        n += 1

Para poder acceder a los valores de un generador lo primero que tenemos que hacer es guardar la 'iteración' en una variable contenedor (objeto iterable).

>  numeros = genera_numeros(100)

Esto nos ha creado un 'objeto generador' pero no ejecuta el código que se encuentra dentro. Si por ejemplo ahora usamos la función next() que se puede traducir como 'siguiente' 

>>> next(numeros)
1
>>> next(numeros)
2
>>> next(numeros)
3
Vemos que al invocar al generador este se ejecuta hasta llegar al yield en donde retorna el valor y espera hasta que vuelve a ser llamado, manteniendo la función en espera y sin borrar los datos locales de la función. En la siguiente llamada se ejecuta el comando siguiente al yield, dando a n el valor que ya tenia más uno es decir 2 y se repite otra vez el mismo proceso. Así hasta llegar a 100.

Como es un objeto iterable podemos usar un bucle for para recorrerlo:

>>> for numero in numeros:
...    print(numero)
... 
4
5
....
99
O también podemos usar una lista para recorrerlo. Vamos a crear otro objeto y lo vemos:

> numeros_b = genera_numeros(100)
> list(numero_b)
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 
21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 
39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 
57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 
75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 
93, 94, 95, 96, 97, 98, 99]

Otro ejemplo:

def generador():
    n = 1
    yield n

    n += 1
    yield n

    n += 1
    yield n

> g = generador()
> next(g)
 1
> next(g)
 2
> next(g)
 3

Lo que ocurre es lo siguiente. Se entra en la función generadora, en la que n=1 y se devuelve ese valor. La función se pone en estado de espera y el valor de n se guarda hasta la siguiente llamada. La segunda vez que usamos next() se entra de nuevo en la función, pero se continua su ejecución desde donde se dejo antes. Se suma 1 a la variable n y se devuelve el valor n=2. En la tercera llamada ocurre exactamente lo mismo. Una cuarta llamada daría un error porque no hay más código que ejecutar. (Habría que poner un return final vacío para que no nos diese error)

Forma Alternativa.

Los generadores también pueden ser creados de una forma mucho más sencilla y con una sola línea de código. Su sintaxis es muy similar a la compresión de listas pero utilizando paréntesis () en lugar de corchetes [].

Por ejemplo si tenemos una serie de números y queremos elevarlos al cuadrado haríamos la siguiente compresión de listas.

numeros = [2,3,7,9,12]
cuadrados = [x**2 for x in numeros]
print(cuadrados)
[4, 9, 49, 81, 144]

y su equivalente usando un generador sería:

generador_cuadrados = (x**2 for x in numeros)
print(generador_cuadrados)
<generator object <genexpr> at 0x7f6e68e4f5f0>

y como hemos visto podemos sacar sus datos mediante un bucle for o convirtiéndolo en una lista.

for i in generador_cuadrados:
    print(i)
4
9
49
81
144

La diferencia entre usar una compresión de listas y un generador es que en el caso de los generadores, los valores no están almacenados en memoria, sino que se van generando al vuelo lo que es una de las principales ventajas de usar generadores. Piensa que un mundo como el actual, en lo que predominan los BIG DATA, una lista puede tener millones de datos con lo que usar generadores que tramitan uno a uno los datos sin tenerlos que tener todos en memoria, es una gran ventaja.

YIELD FROM.

Sirve para simplificar código de los generadores en caso de utilizar bucles anidados. Por ejemplo vamos a crear un generador que nos devuelva una a una una serie de ciudades que previamente hemos definido. 

Antes comentar que cuando en python ponemos un * asterisco delante del parámetro de una función le estamos diciendo a Python que recibirá un numero indeterminado de elementos y que los va a recibir en UNA tupla. El argumento es una tupla con varios elementos, pero un argumento. Por tanto el código quedaría:

def devuelve_ciudades(*args):
    '''generador que devuelve ciudades de una en una'''
    for ciudad in args:
        yield from ciudad
       
    
ciudades=('Bilbao', 'Sevilla', 'Soria', 'León')
ciudad_devuelta = devuelve_ciudades(ciudades)
print(next(ciudad_devuelta))
print(next(ciudad_devuelta))
print(next(ciudad_devuelta))
print(next(ciudad_devuelta))
print(next(ciudad_devuelta))
Bilbao
Sevilla
Soria
León
Traceback (most recent call last):
  File "<string>", line 13, in <module>
StopIteration
# NOTA: la última llamada esta puesta a posta para mostrar que da un error StopIteration
Si solo usásemos 'yield ciudad' nos devolvería el único argumento con todas las ciudades en la primera llamada y luego daría error.

Este código es más facil que tener que usar un bucle anidado como este que seria el que tendriamos que usar si no pusiesemos un 'yield from':

def devuelve_ciudades(*args):
    '''generador que devuelve ciudades de una en una'''
    for elemento in args:
        for ciudad in elemento:
            yield ciudad


lunes, 29 de noviembre de 2021

Trucos para usar Cron como asistente de tareas.


 ¿Que es Cron y para que sirve?


Cron es una administrador de tareas de Linux que nos permitirá ejecutar comandos para por ejemplo realizar copias de seguridad, borrar archivos u otras tareas en un momento determinado. (cada hora, mes, en una fecha en concreto etc) y sin que nos tengamos que preocupar por ello. Es un demonio, un proceso que se ejecuta en segundo plano.

Cada usuario del sistema tiene lo que se llaman tablas de cron o "crontab". No es más que un archivo de texto, que posee una lista con todos los script o tareas a ejecutar. Podemos editarlas, verlas o incluso borrarlas. Además los usuarios podemos usar esas "crontab" para programar tareas. El programa "Cron" por si mismo, monitoriza automáticamente sus crontab y se asegura de cualquier trabajo que aparezca en un crontab se ejecute en el momento especificado.

Configurando Cron.


Podemos editar nuestro crontab particular usando el comando "crontab" junto con la opción -e (editar).  Si por defecto no tienes un editor predeterminado puedes establecerlo editando el archivo ~/.bashrc o incluso en el propio programa al ejecutar la orden. 

Yo voy a configurar el mío para que use "nano" y "gedit". La variable de entorno EDITOR establece que programa de edición usaremos cuando usemos el terminal, mientras que la variable VISUAL define que editor se usará en modo gráfico.

Abrimos el terminal y editamos el archivo ~/.bashrc

pi@raspberrypi:~ $ nano ~/.bashrc

y al final del mismo añadimos:

export EDITOR=nano
export VISUAL=gedit
Nota. He usado el editor nano como editor de terminal y gedit como editor en modo grafico, pero puedes usar el que más te guste.


Guardamos el archivo y refrescamos nuestra sesión de shell para que tenga en cuenta los nuevos cambios:

pi@raspberrypi:~ $ source ~/.bashrc
Ahora ya podemos editar o crear las "crontab" con nuestro editor preferido:


pi@raspberrypi:~ $ crontab -e
crontab -e


Programando una tarea.


El sistema cron funciona esencialmente como un calendario. Podemos decirle con que frecuencia queremos ejecutar un trabajo mediante el uso de cinco parámetros diferentes:

minuto, hora, día del mes, mes y día de la semana.

Los parámetros siguen este orden estricto, aunque veras que no es muy intuitivo. Para empezar, podemos pensar que el programa está configurado para ejecutar algo, siempre o cada x tiempo.



opciones de configuración de crontab



Veámoslo con un ejemplo. Si añadiésemos la siguiente entrada:


* * * * * touch /tmp/hola

Ejecutaríamos la orden touch /tmp/hola cada minuto, de cada hora, de cada día, durante todo el año. Vamos para siempre.

Lógicamente podemos restringir esto un poco, estableciendo valores a cada uno de los atributos. Por ejemplo para que la orden se ejecute en el minuto 30 podemos usar:

30 * * * * touch /tmp/hola

Podemos acotar un poco más añadiendo que se ejecute a una hora especifica (formato de 24 horas). Esta orden ejecuta el comando a las 3.30 am de cada día, todas las mañanas:

30 3 * * * touch /tmp/hola

También podemos hacer que se ejecute a esa hora pero solo los primeros días de cada mes:

30 3 1 * * touch /tmp/hola

Para poner el mes usaremos la forma numérica. El 1 para Enero, el 2 para febrero y así sucesivamente hasta el 12 para Diciembre. 

Lo mismo ocurre si queremos especificar los días de la semana, aunque tendremos que hacerlo en el orden anglosajón.  Es decir la semana comienza en Domingo y termina el Sábado. De esta forma el Domingo es el 0, el lunes el 1, el martes el 2, el miércoles el 3, el jueves el 4, el viernes el 5 y el sábado el 6.

Para verlo mejor. Si queremos que la tarea se ejecute a las 3 horas y 30 minutos durante el mes de abril pero sólo los lunes:

30 3 0 4 1 touch /tmp/hola

Usar Incrementos.


En todos los ejemplos anteriores hemos usado valores exactos. Pero también podemos usar cron para que ejecute una tarea pasado un periodo de tiempo. Por ejemplo podemos programar una tarea para que se repita cada 15 minutos, ojo ¡no en el minuto 15!

*/15 * * * * touch /tmp/hola
o podemos ejecutarla a las 10 de la mañana cada 3 días:

* 10 */3 * * touch /tmp/hola
o programarla para cada 6 horas:

* */6 * * * touch /tmp/hola

Formas abreviadas de Cron.


En las implementaciones más modernas de Cron también podemos usar una forma abreviada en lugar de la estructura que hemos visto anteriormente. Estas son:
  • @hourly - todas las horas durante su primer minuto = 0 * * * *
  • @daily - todos los dias a las 12 de la noche = 0 0 * * *
  • @weekly - todas las semanas, el primer minuto de la primera hora de las semana
             = 0 0 * * 0    
  • @monthly - todos los meses el primer dia = 0 0 1 * *
  • @yearly or @annually - se ejecuta una sola vez al año = 0 0 1 1 *
  • @reboot - se ejecuta una vez y nada más iniciarse el equipo.


Listar las tareas de Cron.


Usando la siguiente instrucción, podemos ver una lista con las tareas que tenemos en crontab.

$ crontab -l
15 3 * 4 1 touch /tmp/hello

Eliminar un Crontab.


Para eliminar un crontab  lo podemos hacer usando -r. Le podemos añadir también el parámetro -i para que sea interactivo es decir nos pregunte si estamos seguros de hacerlo.


crontab -r -i

Otras cosas sobre Cron.


- Cron es un demonio que ejecuta tareas, por lo que si en el momento de ejecutarlas el ordenador está apagado estas no se realizarán. Si queremos superar esta limitación podemos usar un programa llamado anacron que controlará si Cron ha ejecutado las tareas que tenía asignadas.

- Puede usar un generador en línea para crear el momento en que ejecutar la tarea. Por ejemplo Cron Job Generator te puede echar una mano.

- La tarea a ejecutar la puedes poner directamente, si es un comando sencillo. O puedes crear un script de bash con lo que quieras ejecutar (acuérdate de darle permisos de ejecución o no funcionará). Por ejemplo el siguiente script abre firefox en una ventana gráfica (Hay que poner un DISPLAY para abrir cualquier aplicación en un entorno gráfico):

tarea.sh 

#!/bin/bash

env DISPLAY=:0 firefox
todo los días a las 20:38 pm

crontab

38 20 * * * /home/pi/tarea.sh