<jupyter_start><jupyter_text>Introducción a las ciencias de la computación *y programación en Python* Programación orientada a objetos: herencia y buenas prácticas <NAME>  Abstract>  "*I'm not a great programmer*; *I'm just a good programmer with great habits.*" **<NAME>**- Programación orientada a objetos.      - Profundizaremos en la programación orientada a objetos.      - Veremos el proceso de **ocultado de información y detalles**.    - Veremos las **variables de clase**.  - Herencia de clases    - Conoceremos el concepto de herencia y cómo puede aplicarse.  Implementación vs. utilización* Hemos visto cómo escribir código desde dos perspectivas diferentes:      - **Implementar** un nuevo tipo de objeto con una clase:        - **Definir** la clase.                - Definir los **atributos** de la clase. ¿Qué es el objeto?                - Definir los **métodos** de la clase. ¿Cómo se usa el objeto?            - **Utilizar** el nuevo tipo de objeto en el código.         - Crear **instancias** del nuevo tipo.                - Realizar **operaciones** con los objetos.      Definición del clase- El nombre de la clase es el **tipo**: > `class Coordinate(object)`- La clase está definida genéricamente:        - Utilizamos `self` para referirnos a una instancia al definir la clase.    > `(self.x–self.y)**2`        - `self` es un **parámetro** para los métodos en la definición.    - La clase define datos y métodos comunes a través de todas las instancias.  Instancia de una clase- La instancia es **un objeto específico** de una clase:> `coord = Coordinate(1,2)`- Los atributos de datos varían entre instancias:    > `c1 = Coordinate(1,2)`      `c2 = Coordinate(3,4)`        - `c1` y `c2` ¡tienen diferentes coordenadas!    - La instancia **posee la estructura de la clase**.  Por qué utilizar OOP y clases de objetos- La idea es copiar el comportamiento de la vida real.- Agrupamos diferentes objetos que son del mismo tipo.![Tipos de animales](animals.png) ![Tipos de personas](person-prototype.png)  Grupos de objetos con atributos* **Atributos de datos**         - ¿Qué son?        - Para un `Coordinate`: valores $x$ e $y$.        - Para un `Animal`: *nombre* y *edad*.* **Métodos**        - ¿Cómo puede alguien interactuar con el objeto?        - ¿Qué hace?        - Para un `Coordinate`: *encontrar la distancia entre dos coordenadas*.        - Para un `Animal`: *hacer un sonido*.  ¿Cómo definir una clase?Recordemos la sintaxis para definir una clase:<jupyter_code>class Animal(object):
    def __init__(self, age):
        self.age = age
        self.name = None
        
myanimal = Animal(3)<jupyter_output><empty_output><jupyter_text>***Señalemos cada una de las partes de este código***<jupyter_code>myanimal
print(myanimal.age)
print(myanimal.name)<jupyter_output>None<jupyter_text>Métodos *getter* y *setter*Es una *buena práctica* utilizar métodos para **modificar** y **obtener** los atributos de datos de una clase.* Estos métodos deben utilizarse **fuera** de la clase para acceder a los atributos.<jupyter_code>class Animal(object):
    def __init__(self, age):
        self.age = age
        self.name = None
    # Getters
    def get_age(self):
        return self.age
    def get_name(self):
        return self.name
    
    # Setters
    def set_age(self, newage):
        self.age = newage
    def set_name(self, newname=""):
        self.name = newname
        
    def __str__(self):
        return "animal:"+str(self.name)+":"+str(self.age)
a = Animal(4)
print(a)
print(a.get_age())
# Cambiamos el nombre
a.set_name("fluffy")
print(a)<jupyter_output>animal:fluffy:4<jupyter_text>Notación `.`- La instanciación crea una **instancia** de un objeto:<jupyter_code>a = Animal(3)<jupyter_output><empty_output><jupyter_text>- La notación `.` permite acceder a los atributos (datos y métodos), aunque es **mejor práctica** utilizar métodos para obtener y ajustar estos parámetros.<jupyter_code>a.age
a.get_age()<jupyter_output><empty_output><jupyter_text>Ocultando los detalles: ¿por qué es mejor práctica?- El autor de la definición de la clase **podría querer cambiar el nombre de los atributos**<jupyter_code>class Animal(object):
    def __init__(self, age):
        self.years = age
    def get_age(self):
        return self.years<jupyter_output><empty_output><jupyter_text>**Note la utilización de `self.years` en lugar de `self.age`**- Si se ha cambiado la definición de la clase, al acceder **fuera de la clase** a los atributos, se **podrían obtener errores**.- Es mejor utilizar métodos ***setters*** y ***getters*** como `a.get_age()` y **no** `a.age`    - Mejor estilo.    - Es más fácil mantener el código.    - Previene *bugs*.  Ocultando los detalles: Python no es bueno en esto- Python permite **acceder a los datos** fuera de la clase:<jupyter_code>print(a.age)<jupyter_output>3<jupyter_text>- Permite **escribir los atributos** fuera de la clase directamente:<jupyter_code>a.age = 'infinito'<jupyter_output><empty_output><jupyter_text>- Permite **crear atributos de datos** de una instancia, fuera de la definición de la clase.<jupyter_code>a.size = "tiny"<jupyter_output><empty_output><jupyter_text>- Sin embargo, ¡**no es buena práctica** hacer cualquiera de estas!  Argumentos por defecto- Permiten proveer un **valor por defecto** a los parámetros formales si no se pasa ningún parámetro.<jupyter_code>class Animal(object):
    def __init__(self, age):
        self.age = age
        self.name = None
    # Getters
    def get_age(self):
        return self.age
    def get_name(self):
        return self.name
    
    # Setters
    def set_age(self, newage):
        self.age = newage
    def set_name(self, newname=""):
        self.name = newname
        
    def __str__(self):
        return "animal:"+str(self.name)+":"+str(self.age)
def set_name(self, newname=""):
    self.name = newname<jupyter_output><empty_output><jupyter_text>- Al utilizar el argumento por defecto, podríamos no pasar ningún parámetro.<jupyter_code>a = Animal(3)
a.set_name()
print(a.get_name())
# Imprime ""<jupyter_output><jupyter_text>- Acá un ejemplo con argumento:<jupyter_code>a = Animal(3)
a.set_name("fluffy")
print(a.get_name())<jupyter_output>fluffy<jupyter_text>- Acá un ejemplo con el parámetro por defecto<jupyter_code>a.set_name()
print(a.get_name())<jupyter_output><jupyter_text>Jerarquías![Jerarquías de la clase `Animal`](jerarquias.png) ![Tipos de vehículos](vehicle-class.jpeg)  JerarquíasLas jerarquías definen:  - Una **clase padre**: *superclase*.- Una **clase hija**: *subclase*.        - **Hereda** todos los atributos y métodos de la clase padre.        - Es posible **añadir** más atributos.        - También **más comportamiento**.        - Es posible **modificar** el comportamiento (*override of methods*).    ![Jerarquías de animales](jerarquias-animales.png)  Herencia: la clase padre- Todo es un objeto.- Hereda de la clase `object`.        - Permite implementar operaciones básicas de Python.<jupyter_code># Clase Animal completa
class Animal(object):
    def __init__(self, age):
        self.age = age
        self.name = None
    def get_age(self):
        return self.age
    def get_name(self):
        return self.name
    def set_age(self, newage):
        self.age = newage
    def set_name(self, newname=""):
        self.name = newname
    def __str__(self):
        return "animal:"+str(self.name)+":"+str(self.age)<jupyter_output><empty_output><jupyter_text>Herencia: la clase hija- Agrega funcionalidad con el método `speak()`.        - Instancia de tipo `Cat` puede ser llamada con nuevos métodos.        - Instancia de tipo `Animal` **arroja un error** si se llama con el núevo método de `Cat`.    - El método `__init__` no falta, **utiliza** la versión de la clase `Animal`.<jupyter_code>class Cat(Animal):
    def speak(self):
        print("meow")
    def __str__(self):
        return "cat:"+str(self.name)+":"+str(self.age)<jupyter_output><empty_output><jupyter_text>***Note cómo se modifica el método `__str__`***.<jupyter_code>c = Cat(5)
c.set_name("fluffy")
print(c)
c.speak()
print(c.get_age())<jupyter_output>5<jupyter_text>¿Qué método utilizar?- La subclase puede tener **métodos con el mismo nombre** que la superclase.- Una instancia busca el método en la definición de su **clase actual**.- Si no encuentra el método, busca **hacia arriba en la jerarquía**.    - En la clase padre, clase "abuelo", etc.- Utiliza el **primer método** en la jerarquía que contenga el nombre invocado.<jupyter_code>class Person(Animal):
    def __init__(self, name, age):
        Animal.__init__(self, age)
        self.set_name(name)
        self.friends = []
    def get_friends(self):
        return self.friends
    def speak(self):
        print("hello")
    def add_friend(self, fname):
        if fname not in self.friends:
            self.friends.append(fname)
    def age_diff(self, other):
        diff = self.age - other.age
        print(abs(diff), "year difference")
    def __str__(self):
        return "person:"+str(self.name)+":"+str(self.age)<jupyter_output><empty_output><jupyter_text>- ***¿Cuál es la diferencia en el método constructor?***- ***¿Cuáles son los nuevos métodos de esta clase?***- ***¿Se sobreescribe (modifica) algún método?***<jupyter_code># Pruebas de Person
p1 = Person("jack", 30)
p2 = Person("jill", 25)

print(p1.get_name(), p1.get_age())
print(p2.get_name(), p2.get_age())
print(p1)
p1.speak()
p1.age_diff(p2)<jupyter_output>5 year difference<jupyter_text>Ejemplo: clase nieto<jupyter_code>import random

class Student(Person):
    def __init__(self, name, age, major=None):
        Person.__init__(self, name, age)
        self.major = major
    def __str__(self):
        return "student:"+str(self.name)+":"+str(self.age)+":"+str(self.major)
    def change_major(self, major):
        self.major = major
    def speak(self):
        r = random.random()
        if r < 0.25:
            print("i have homework")
        elif 0.25 <= r < 0.5:
            print("i need sleep")
        elif 0.5 <= r < 0.75:
            print("i should eat")
        else:
            print("i am watching tv")<jupyter_output><empty_output><jupyter_text>- ***¿De qué clase hereda ahora `Student`?***- ***¿Por qué se realiza `import random`?***- ***¿Se agrega algún atributo?***- ***¿Se sobreescribe (modifica) algún método?***<jupyter_code># Pruebas de Student
s1 = Student('alice', age=20, major="CS")
s2 = Student('beth', age=18)

print(s1)
print(s2)
print(s1.get_name(),"says:", end=" ")
s1.speak()
print(s2.get_name(),"says:", end=" ")
s2.speak()<jupyter_output>beth says: i am watching tv<jupyter_text>Variables de clase- Sus valores se **comparten** entre todas las instancias de una clase.<jupyter_code>class Rabbit(Animal):
    # a class variable, tag, shared across all instances
    tag = 1
    def __init__(self, age, parent1=None, parent2=None):
        Animal.__init__(self, age)
        self.parent1 = parent1
        self.parent2 = parent2
        self.rid = Rabbit.tag
        Rabbit.tag += 1<jupyter_output><empty_output><jupyter_text>- Utilizamos `tag` para dar un **identificador único** a cada instancia de `Rabbit`.- ¿Cuál es la diferencia entre una **variable de instancia** y una **variable de clase**?    - Al incrementar la variable de clase, cambia para **todas las instancias** de la clase.<jupyter_code># Prueba de conejos
r1 = Rabbit(3)
r2 = Rabbit(4)

print("r1:", r1)
print("r2:", r2)
#tag se queda en el valor
r1.tag
# de forma global
r2.tag<jupyter_output><empty_output><jupyter_text>Métodos *getter* para `Rabbit`- `get_rid()`: devuelve el ID del conejo.- `get_parent1()` y `get_parent2()` devuelven a los padres del conejo.<jupyter_code>class Rabbit(Animal):
    # a class variable, tag, shared across all instances
    tag = 1
    def __init__(self, age, parent1=None, parent2=None):
        Animal.__init__(self, age)
        self.parent1 = parent1
        self.parent2 = parent2
        self.rid = Rabbit.tag
        Rabbit.tag += 1
    def get_rid(self):
        # zfill used to add leading zeroes 001 instead of 1
        return str(self.rid).zfill(3)
    def get_parent1(self):
        return self.parent1
    def get_parent2(self):
        return self.parent2<jupyter_output><empty_output><jupyter_text>- Recordemos que `get_name()` y `get_age()` fueron heredados de la clase `Animal`.  Creando un operador para `Rabbit`- Definimos el operador `+` entre dos instancias de `Rabbit`.        - Si `r1` y `r2` son instancias de `Rabbit`: `r4 = r1 + r2` definen un conejo con padres `r1` y `r2`.        - Se define al conejo `r4` con edad inicial cero.        - Un padre es `self` y el otro es `other`.        - Recordemos que en `__init__`, `parent1` y `parent2` son de tipo `Rabbit`.<jupyter_code>def __add__(self, other):
    # returning object of same type as this class
    return Rabbit(0, self, other)
# Esta es parcial, para probar la clase Rabbit con el método __add__
class Rabbit(Animal):
    # a class variable, tag, shared across all instances
    tag = 1
    def __init__(self, age, parent1=None, parent2=None):
        Animal.__init__(self, age)
        self.parent1 = parent1
        self.parent2 = parent2
        self.rid = Rabbit.tag
        Rabbit.tag += 1
    def get_rid(self):
        # zfill used to add leading zeroes 001 instead of 1
        return str(self.rid).zfill(3)
    def get_parent1(self):
        return self.parent1
    def get_parent2(self):
        return self.parent2
    def __add__(self, other):
        # returning object of same type as this class
        return Rabbit(0, self, other)
# Prueba de conejos
r1 = Rabbit(3)
r2 = Rabbit(4)
r4 = r1 + r2
r4.get_parent1()
r4.get_parent2()<jupyter_output><empty_output><jupyter_text>Método para comparar dos `Rabbit`- Vamos a decidir que dos conejos son iguales si **tienen a los mismos padres**.<jupyter_code>def __eq__(self, other):
    # compare the ids of self and other's parents
    # don't care about the order of the parents
    # the backslash tells python I want to break up my line
    parents_same = self.parent1.rid == other.parent1.rid \
                   and self.parent2.rid == other.parent2.rid

    parents_opposite = self.parent2.rid == other.parent1.rid \
                       and self.parent1.rid == other.parent2.rid

    return parents_same or parents_opposite<jupyter_output><empty_output><jupyter_text>- Comparamos los IDs de los padres, ya que estos son únicos debido a **las variables de clase**. - Notar que no es posible comparar a los objetos padres directamente.    - Por ejemplo: `self.parent1 == other.parent1`    - Esto llamaría a `__eq__` hacia los padres de los conejos, hasta eventualmente obtener `None`, lo cual provocaría `AtributeError` al intentar hacer `None.parent1`.<jupyter_code># Clase completa de Rabbit y ejemplos
class Rabbit(Animal):
    # a class variable, tag, shared across all instances
    tag = 1
    def __init__(self, age, parent1=None, parent2=None):
        Animal.__init__(self, age)
        self.parent1 = parent1
        self.parent2 = parent2
        self.rid = Rabbit.tag
        Rabbit.tag += 1
    def get_rid(self):
        # zfill used to add leading zeroes 001 instead of 1
        return str(self.rid).zfill(3)
    def get_parent1(self):
        return self.parent1
    def get_parent2(self):
        return self.parent2
    def __add__(self, other):
        # returning object of same type as this class
        return Rabbit(0, self, other)
    def __eq__(self, other):
        # compare the ids of self and other's parents
        # don't care about the order of the parents
        # the backslash tells python I want to break up my line
        parents_same = self.parent1.rid == other.parent1.rid \
                       and self.parent2.rid == other.parent2.rid
        parents_opposite = self.parent2.rid == other.parent1.rid \
                           and self.parent1.rid == other.parent2.rid
        return parents_same or parents_opposite
    def __str__(self):
        return "rabbit:"+ self.get_rid()
# Creando instancias de Rabbit
r1 = Rabbit(3)
r2 = Rabbit(4)
r3 = Rabbit(5)
print("r1:", r1)
print("r2:", r2)
print("r3:", r3)
print("r1 parent1:", r1.get_parent1())
print("r1 parent2:", r1.get_parent2())
r4 = r1+r2
print("r4 parent1:", r4.get_parent1())
print("r4 parent2:", r4.get_parent2())
# Probando igualdad de conejos
r5 = r3+r4
r6 = r4+r3

print("r5 parent1:", r5.get_parent1())
print("r5 parent2:", r5.get_parent2())
print("r6 parent1:", r6.get_parent1())
print("r6 parent2:", r6.get_parent2())
print("r5 and r6 have same parents?", r5 == r6)
print("r4 and r6 have same parents?", r4 == r6)<jupyter_output>r5 and r6 have same parents? True
r4 and r6 have same parents? False
{'fraction_non_alphanumeric': 0.1185375777884155, 'fraction_numerical': 0.014301101005265677, 'mean_word_length': 3.4782958199356915, 'pattern_counts': {'":': 7, '<': 102, '<?xml version=': 0, '>': 102, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '26', 'dir': 'jupyter-structured-clean-dedup', 'id': '392202', 'license': 'MIT', 'n_tokens_mistral': 5848, 'n_tokens_neox': 5695, 'n_words': 1726, 'path': 'presentaciones/09-oop-inheritance/09-oop-inheritance.ipynb', 'repo_name': 'r2cp/PES-Programacion-I', 'size': '16741'}