# POO
# La programación orientada a objetos está basada en 6 principios o pilares básicos:
#     Herencia
#     Cohesión
#     Abstracción
#     Polimorfismo
#     Acoplamiento
#     Encapsulamiento

# Uno de los primeros mecanismos que se crearon fueron las funciones, 
# que permiten agrupar bloques de código que hacen una tarea específica bajo un nombre. 
# Algo muy útil ya que permite también reusar esos módulos o funciones sin tener que copiar todo el código, 
# tan solo la llamada.

# Definiendo clases
# Creando una clase vacía
class Participante:
    pass

# Creando una clase vacía
class Trabajadorxs:
    pass

# Creamos un objeto de la clase Participante
participante1 = Participante()

# atributos de instancia 
# método __init__ que será llamado automáticamente cuando creemos un objeto. 
# constructor.

class Participante:
    # El método __init__ es llamado al crear el objeto
    def __init__(self, nombre, apellido):
        print(f"Creando Participante {nombre}, {apellido}")

        # Atributos de instancia
        self.nombre = nombre
        self.apellido = apellido

participante1 = Participante("Gustavo", "Rivero")
print(type(participante1))

print(participante1.nombre) 	# Gustavo
print(participante1.apellido)   # Rivero

class Trabajadorxs:
    # Atributo de clase
    sector = 'publico'

    # El método __init__ es llamado al crear el objeto
    def __init__(self, nombre, apellido):
        print(f"Creando Trabajadorxs {nombre}, {apellido}")

        # Atributos de instancia
        self.nombre = nombre
        self.apellido = apellido

# Dado que es un atributo de clase, no es necesario crear un objeto para acceder al atributos. Podemos hacer lo siguiente.

print(Trabajadorxs.sector)

# Se puede acceder también al atributo de clase desde el objeto.

trabajadorxs2 = Trabajadorxs("Adolfo", "Castañeda")
trabajadorxs2.sector = 'Privado'
print(trabajadorxs2.sector)

class Trabajadorxs:
    # Atributo de clase
    sector = 'público'

    # El método __init__ es llamado al crear el objeto
    def __init__(self, nombre, apellido):
        print(f"Creando Trabajadorxs {nombre}, {apellido}")

        # Atributos de instancia
        self.nombre = nombre
        self.sector = apellido

    def clap(self):
        print("Entregado")

    def evaluacion(self, puntos):
        print(f"Obtuvo {puntos} puntos")

trabajadorxs3 = Trabajadorxs("María", "Magdalena")
trabajadorxs3.clap()
trabajadorxs3.evaluacion(10)

##########################################################
###                 Tipos de métodos                   ###
##########################################################

# Métodos de instancias
class Clase1:
    def metodo(self):
        return 'Método normal', self

    @classmethod
    def metododeclase(cls):
        return 'Método de clase', cls

    @staticmethod
    def metodoestatico():
        return "Método estático"

class Clase2:
    def metodo(self, arg1, arg2):
        return 'Método normal', self

mi_clase2 = Clase2()
mi_clase2.metodo("a", "b")

# Métodos de clases
# Métodos de clase (classmethod)
# A diferencia de los métodos de instancia, los métodos de clase reciben como argumento cls, que hace referencia a la clase. 
# Por lo tanto, pueden acceder a la clase pero no a la instancia.

class Clase3:
    @classmethod
    def metododeclase(cls):
        return 'Método de clase', cls

Clase3.metododeclase()

# Pero también se pueden llamar sobre el objeto.
mi_clase3 = Clase3()
mi_clase3.metododeclase()

# Métodos estáticos (staticmethod)
# métodos estáticos se pueden definir con el decorador @staticmethod
# No aceptan como parámetro ni la instancia ni la clase. 
# Es por ello por lo que no pueden modificar el estado ni de la clase ni de la instancia. 
# Pero por supuesto pueden aceptar parámetros de entrada.

class Clase4:
    @staticmethod
    def metodoestatico():
        return "Método estático"

mi_clase4 = Clase4()
Clase4.metodoestatico()
mi_clase4.metodoestatico()

##########################################################
###                 Herencia en Python                 ###




##########################################################

# Definimos una clase padre
class Persona:
    pass

# Creamos una clase hija que hereda de la padre
class Participante(Persona):
    pass

# De hecho podemos ver como efectivamente la clase Participante es la hija de Animal usando __bases__

print(Participante.__bases__)

De manera similar podemos ver que clases descienden de una en concreto con __subclasses__.

print(Persona.__subclasses__())

# filosofía DRY. 
# El principio DRY (Don't Repeat Yourself) 
# no repetir código de manera innecesaria. 

class Persona:
    def __init__(self, nombre, edad):
        self.nombre = nombre
        self.edad = edad

    # Método genérico pero con implementación particular
    def hablar(self):
        # Método vacío
        pass

    # Método genérico pero con implementación particular
    def moverse(self):
        # Método vacío
        pass

    # Método genérico con la misma implementación
    def describeme(self):
        print("Soy una persona del tipo", type(self).__name__)

# Participante hereda de Persona
class Participante(Persona):
    pass

Participante1 = Participante('Marta', 18)
Participante1.describeme()

class Participante(Persona):
    def hablar(self):
        print("Hablo venezolano!")
    def moverse(self):
        print("Caminando con 2 piernas")

class Trabajadorxs(Persona):
    def hablar(self):
        print("Hablo caraqueño!")
    def moverse(self):
        print("Caminando con 2 piernas")

class Aprendices(Persona):
    def hablar(self):
        print("Hablo maracucho!")
    def moverse(self):
        print("Volando")

    # Nuevo método
    def picar(self):
        print("Picar!")

#    Heredados directamente de la clase padre: describeme()
#    Heredados de la clase padre pero modificados: hablar() y moverse()
#    Creados en la clase hija por lo tanto no existentes en la clase padre: picar()

aprendiz4 = Aprendices('Rafael', 10)
participante4 = Participantes('Julia', 23)
trabajadorxs4 = Trabajadorxs('Karla', 19)

aprendiz4.hablar()
participante4.hablar()


participante4.describeme()
trabajadorxs4.describeme()

trabajadorxs4.picar()

# Uso de super()
# La función super() nos permite acceder a los métodos de la clase padre desde una de sus hijas. 

class Persona:
    def __init__(self, sector, edad):
        self.sector = sector
        self.edad = edad        
    def hablar(self):
        pass

    def moverse(self):
        pass

    def describeme(self):
        print("Soy una persona del tipo", type(self).__name__)

# Tal vez queramos que nuestro clase tenga un parámetro extra en el constructor. Para realizar esto 
# tenemos dos alternativas:

# Podemos crear un nuevo __init__ y guardar todas las variables una a una.
# O podemos usar super() para llamar al __init__ de la clase padre que ya aceptaba el sector y edad, y 
# sólo asignar la variable nueva manualmente.

class Participante(Persona):
    def __init__(self, sector, edad, cedula):
        # Alternativa 1
        # self.sector = sector
        # self.edad = edad
        # self.cedula = cedula

        # Alternativa 2
        super().__init__(sector, edad)
        self.cedula = cedula

Participante5 = Participante('mamífero', 7, 'Luis')
Participante5.sector
Participante5.edad
Participante5.cedula

# Herencia múltiple

class Clase1:
    pass
class Clase2:
    pass
class Clase3(Clase1, Clase2):
    pass

# Es posible también que una clase herede de otra clase y a su vez otra clase herede de la anterior.

class Clase1:
    pass
class Clase2(Clase1):
    pass
class Clase3(Clase2):
    pass

# Llegados a este punto nos podemos plantear lo siguiente. Vale, como sabemos de otros posts las clases 
# hijas heredan los métodos de las clases padre, pero también pueden reimplementarlos de manera distinta.
# Entonces, si llamo a un método que todas las clases tienen en común ¿a cuál se llama?. Pues bien, existe
# una forma de saberlo.

# La forma de saber a que método se llama es consultar el MRO o Method Order Resolution. Esta función nos
# devuelve una tupla con el orden de búsqueda de los métodos. Como era de esperar se empieza en la propia
# clase y se va subiendo hasta la clase padre, de izquierda a derecha.

class Clase1:
    pass
class Clase2:
    pass
class Clase3(Clase1, Clase2):
    pass

print(Clase3.__mro__)

class Clase1:
    pass
class Clase2:
    pass
class Clase3:
    pass
class Clase4(Clase1, Clase3, Clase2):
    pass
print(Clase4.__mro__)

# Junto con la herencia, la cohesión, abstracción, polimorfismo, acoplamiento y encapsulamiento son otros
# de los conceptos claves para entender la programación orientada a objetos.
'''
