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.