{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<p>\n",
    "<font size='5' face='Georgia, Arial'>IIC2233 Apunte Programación Avanzada</font><br>\n",
    "<font size='1'>&copy; 2015 Karim Pichara - Christian Pieringer. Todos los derechos reservados.</font>\n",
    "<br>\n",
    "<font size='1'> Modificado desde 2017-2 al 2026-1 por Equipo Docente IIC2233</font>\n",
    "</p>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Tabla de contenidos\n",
    "1. [Herencia](#Herencia)\n",
    "    1. [Ejemplo: especializando la clase `Auto`](#Ejemplo:-especializando-la-clase-Auto)\n",
    "    2. [Sobreescritura de métodos: *overriding*](#Sobreescritura-de-métodos:-overriding)\n",
    "    3. [Obtener clase superior: `super()`](#Obtener-clase-superior:-super())\n",
    "    4. [Ejemplo: herencia con built-ins](#Ejemplo:-Herencia-con-built-ins)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Herencia\n",
    "\n",
    "La **herencia** (*inheritance*) es una de las características más importantes de OOP, y corresponde a una relación de **especialización** y **generalización** entre clases. En esta relación, una **clase** _hereda_ atributos y métodos de otra. Decimos que la que hereda es una **subclase**, y la otra es una **superclase**. La subclase posee todos los atributos y métodos de la superclase, pero además tiene sus propios métodos y atributos específicos, que se consideran una especialización. El concepto de herencia nos permite aprovechar (reutilizar) código de las clases de las cuales se hereda.\n",
    "\n",
    "## Ejemplo: especializando la clase `Auto`\n",
    "\n",
    "Consideremos una clase `Auto`. Algunos de sus atributos son `marca`, `modelo`, `color` y algunos de sus métodos son `conducir`, `leer_odómetro` y `vender`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Auto:\n",
    "\n",
    "    def __init__(self, marca, modelo, año, color, km):\n",
    "        self.marca = marca\n",
    "        self.modelo = modelo\n",
    "        self.año = año\n",
    "        self.color = color\n",
    "        self._kilometraje = km\n",
    "        self._dueño = None\n",
    "\n",
    "    def conducir(self, kms):\n",
    "        print(f\"Conduciendo {kms} kilómetros\")\n",
    "        self._kilometraje += kms\n",
    "\n",
    "    def vender(self, nuevo_dueño):\n",
    "        self._dueño = nuevo_dueño\n",
    "        print(f\"Auto vendido a {nuevo_dueño}\")\n",
    "\n",
    "    def leer_odometro(self):\n",
    "        return self._kilometraje\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Si se nos pide modelar un furgón escolar, es natural pensar que éste compartirá muchas características de un `Auto`. Es más, podríamos decir que un furgón escolar **es un tipo particular de `Auto`**. El furgón escolar debería tener, al menos, _los mismos atributos y métodos_ que un auto, y seguramente tendrá también algunos atributos adicionales como _lista de estudiantes inscritos_ y métodos adicionales como _inscribir estudiante_. Como ya hemos escrito el código de la clase `Auto`, nos gustaría poder **reutilizar** este código, pues ahí ya tenemos algunos atributos y métodos implementados.\n",
    "\n",
    "Usaremos, entonces, la **herencia**. La herencia nos permite _heredar_ datos y comportamientos de una clase y utilizarlos en otra. En nuestro ejemplo del furgón escolar, crearemos una clase `FurgónEscolar` que hereda de `Auto` y definiremos ahí la lista de estudiantes, y un método de inscripción.\n",
    "\n",
    "Si `FurgónEscolar` **hereda** de `Auto`, también se dice que:\n",
    "- `FurgónEscolar` es una **especialización** de la clase `Auto`\n",
    "- `FurgónEscolar` es una **subclase** (o clase hija) de `Auto`\n",
    "- `FurgónEscolar` **extiende** a la clase `Auto`\n",
    "- `Auto` es **superclase** (o clase madre) de `FurgonEscolar`\n",
    "\n",
    "En Python, la implementación de herencia se describe especificando, al momento de definir una clase, el nombre de la clase de la cual hereda."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class FurgonEscolar(Auto): # Aquí se marca de donde hereda\n",
    "    \"\"\"Subclase de Auto\"\"\"\n",
    "    \n",
    "    def __init__(self, marca, modelo, año, color, km):\n",
    "        # Para inicializar algunos datos en la clase madre, llamamos explícitamente \n",
    "        # al __init__ de esa clase. Lo llamaremos usando la clase madre\n",
    "        Auto.__init__(self, marca, modelo, año, color, km)\n",
    "        \n",
    "        # Este atributo existe únicamente para objetos de tipo FurgonEscolar, \n",
    "        # pero no para todos los objetos de clase Auto \n",
    "        self.estudiantes = []\n",
    "    \n",
    "    # inscribir_estudiante es un método específico de esta subclase.\n",
    "    def inscribir_estudiante(self, estudiante):\n",
    "        self.estudiantes.append(estudiante)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Notemos que el inicializador de `FurgonEscolar` no inicializa los atributos `marca`, `modelo`, `año`, `color` y `_kilometraje`. Podría hacerlo, pero es más conveniente que los atributos que ya son inicializados por la clase superior se le entreguen a su inicializador respectivo. Para invocar al inicializador de la clase superior debemos llamar explícitamente el nombre de dicha clase antes del `__init__`; de lo contrario estaríamos llamando recursivamente al mismo `__init__` de la clase actual. Una vez que se ha llamado al `__init__` de la clase superior, podemos continuar inicializando los atributos únicos de `FurgónEscolar`.\n",
    "\n",
    "Podemos comprobar que un objeto de la clase `FurgónEscolar` tiene todos los datos y métodos que tenía la clase `Auto`, y también tiene lo que definimos específicamente para `FurgonEscolar`:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "furgon = FurgonEscolar('Kia', 'Sportage', 2000, \"Blanco\", 135000)\n",
    "print(f\"Marca: {furgon.marca}\")\n",
    "print(f\"Modelo: {furgon.modelo}\")\n",
    "print(f\"Color: {furgon.color}\")\n",
    "furgon.conducir(5)\n",
    "print(f\"Kilometraje: {furgon.leer_odometro()}\")\n",
    "furgon.inscribir_estudiante('Jaimito')\n",
    "furgon.inscribir_estudiante('Danielita')\n",
    "furgon.inscribir_estudiante(\"Toñito\")\n",
    "furgon.inscribir_estudiante('Pablito')\n",
    "furgon.inscribir_estudiante('Tamita')\n",
    "print(f\"Estudiantes: {furgon.estudiantes}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Sobreescritura de métodos: *overriding*\n",
    "\n",
    "En ocasiones, algunos métodos de la superclase (que son heredados por la subclase) no nos sirven exactamente para la subclase y podríamos querer reemplazar o modificar algunos de ellos. En este ejemplo, no queremos que el conductor de un `FurgonEscolar` conduzca igual que un conductor de `Auto`, si no que queremos que lo haga con más cuidado. Después de todo, lleva estudiantes.\n",
    "\n",
    "La herencia nos permite **sobreescribir** los métodos que necesitemos modificar. En Python, podemos **volver a definir un método en una subclase**, con el mismo nombre que tenía en la superclase. De esta manera al ejecutar el método desde un objeto perteneciente a la subclase, se ejecuta la versión del método que está en la subclase, y no la versión que está en la súperclase. En OOP, esto se llama _**overriding**_ y permite que una subclase implemente una versión especializada de un método que es heredado desde una superclase.\n",
    "\n",
    "Revisemos de nuevo el ejemplo del `Auto` y `FurgonEscolar`:\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Auto:\n",
    "    \n",
    "    def __init__(self, ma, mo, a, c, k):\n",
    "        self.marca = ma\n",
    "        self.modelo = mo\n",
    "        self.año = a\n",
    "        self.color = c\n",
    "        self._kilometraje = k\n",
    "        self._dueño = None\n",
    "\n",
    "    def conducir(self, kms):\n",
    "        print(f\"Conduciendo {kms} kilómetros\")\n",
    "        self._kilometraje += kms\n",
    "\n",
    "    def vender(self, nuevo_dueño):\n",
    "        self._dueño = nuevo_dueño\n",
    "        print(f\"Auto vendido a {nuevo_dueño}\")\n",
    "\n",
    "    def leer_odometro(self):\n",
    "        return self._kilometraje\n",
    "\n",
    "    \n",
    "class FurgonEscolar(Auto):\n",
    "    \"\"\"Subclase de Auto\"\"\"\n",
    "    \n",
    "    # Estamos haciendo overriding del __init__ original\n",
    "    def __init__(self, marca, modelo, año, color, kms):\n",
    "        # Aún queremos usar el __init__ original para setear los otros datos. Así es como podemos llamarlo.\n",
    "        Auto.__init__(self, marca, modelo, año, color, kms)\n",
    "        self.estudiantes = []\n",
    "    \n",
    "    # inscribir_estudiante es un método específico de esta subclase.\n",
    "    def inscribir_estudiante(self, estudiante):\n",
    "        self.estudiantes.append(estudiante)\n",
    "        \n",
    "    # Estamos haciendo overriding del método conducir original\n",
    "    def conducir(self, distancia):\n",
    "        # Acá no queremos usar la versión original de conducir\n",
    "        print(f\"Conduciendo con cuidado {distancia} kilómetros\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "En este caso hemos reescrito el método `conducir`, que ya estaba definido en la clase `Auto`, pero ahora dentro de la clase `FurgonEscolar`. Hemos hecho un _override_ del método `conducir`. De esta manera si llamamos a `conducir` desde una instancia de `FurgonEscolar`, se ejecutará el `conducir` que acabamos de reescribir, pero si llamamos a `conducir` desde una instancia de `Auto`, se ejecutará el `conducir` de `Auto`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "auto = Auto('Suzuki', 'Grand Nomade', 2015, \"Naranjo\", 35000)\n",
    "furgon = FurgonEscolar('Kia', 'Sportage', 2000, \"Blanco\", 135000)\n",
    "print(f\"Marca: {furgon.marca}\")\n",
    "print(f\"Modelo: {furgon.modelo}\")\n",
    "print(f\"Color: {furgon.color}\")\n",
    "furgon.conducir(5)\n",
    "print(f\"Kilometraje: {furgon.leer_odometro()}\")\n",
    "furgon.inscribir_estudiante(\"Jaimito\")\n",
    "furgon.inscribir_estudiante(\"Danielita\")\n",
    "furgon.inscribir_estudiante(\"Toñito\")\n",
    "furgon.inscribir_estudiante(\"Pablito\")\n",
    "furgon.inscribir_estudiante(\"Tamita\")\n",
    "print(f\"Estudiantes: {furgon.estudiantes}\")\n",
    "auto.conducir(12)\n",
    "print(f\"Tipo de auto: {type(auto)}\")\n",
    "print(f\"Tipo de furgón: {type(furgon)}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Podemos ver que, al ejecutar el mismo método sobre `auto` y sobre `furgon`, el resultado es distinto. Al ejecutar `furgon.conducir(5)`, se ejecuta el método _especializado_ que se definió para la clase `FurgonEscolar`, mientras que si ejecutamos `auto.conducir(12)`, se ejecuta el método original de la clase `Auto`."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Obtener clase superior: `super()`\n",
    "\n",
    "En el ejemplo del furgón escolar, reimplementamos (hicimos _override_ de) el método inicializar `__init__`. Esto lo hacemos porque también queremos inicializar el nuevo atributo `estudiantes`.  Pero también debemos inicializar los atributos heredados de la clase `Auto`. Si no queremos hacer ninguna modificación a la manera en que éstos se inicializan, podemos llamar explícitamente al método de la superclase. Si escribimos una línea de la forma `SuperClase.metodo(self, argumentos)`, podemos invocar directamente la ejecución del método de la superclase. En nuestro caso, podemos ejecutar `Auto.__init__(self, marca, modelo, motor)` y con esto estamos delegando la inicialización de esos atributos a la superclase.\n",
    "\n",
    "Usando el método `super()` podemos utilizar la implementación de un método de la superclase sin nombrar explícitamente a la clase superior, de la forma `super().metodo(argumentos)`. Esto nos ayuda a escribir un código más mantenible, en caso que decidamos cambiar el nombre de la clase superior. Además, evitará problemas al momento de tener múltiples herencias, donde muchas cosas interesantes pueden pasar. Un buen diseño dicta que este método tenga la misma estructura de llamada en todos los casos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class FurgonEscolar(Auto):\n",
    "    \"\"\"Subclase de Auto\"\"\"\n",
    "    \n",
    "    # Estamos haciendo overriding del __init__ original\n",
    "    def __init__(self, marca, modelo, año, color, kms):\n",
    "        # Aún queremos usar el __init__ original para setear los otros datos. Así podemos llamarlo con super()\n",
    "        super().__init__(marca, modelo, año, color, kms)\n",
    "        self.estudiantes = []\n",
    "    \n",
    "    # inscribir_estudiante es un método específico de esta subclase.\n",
    "    def inscribir_estudiante(self, estudiante):\n",
    "        self.estudiantes.append(estudiante)\n",
    "        \n",
    "    # Estamos haciendo overriding del método conducir original\n",
    "    def conducir(self, distancia):\n",
    "        # Acá no queremos usar la versión original de conducir\n",
    "        print(f\"Conduciendo con cuidado {distancia} kilómetros\")  \n",
    "\n",
    "\n",
    "furgon = FurgonEscolar('Kia', 'Sportage', 2000, \"Blanco\", 135000)\n",
    "print(f\"Marca: {furgon.marca}\")\n",
    "print(f\"Modelo: {furgon.modelo}\")\n",
    "print(f\"Color: {furgon.color}\")\n",
    "furgon.conducir(5)\n",
    "print(f\"Kilometraje: {furgon.leer_odometro()}\")\n",
    "furgon.inscribir_estudiante(\"Jaimito\")\n",
    "furgon.inscribir_estudiante(\"Danielita\")\n",
    "furgon.inscribir_estudiante(\"Toñito\")\n",
    "furgon.inscribir_estudiante(\"Pablito\")\n",
    "furgon.inscribir_estudiante(\"Tamita\")\n",
    "print(f\"Estudiantes: {furgon.estudiantes}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejemplo: Herencia con _built-ins_\n",
    "\n",
    "La herencia nos permite extender, no solamente las clases que hemos definido, sino también algunas que ya existían. En Python le llamamos clases *built-ins* a aquellas que están _construidas dentro_ (\"*built*\"-\"*in*\") del intérprete de Python y no necesitan de ningún módulo adicional para funcionar. \n",
    "\n",
    "Una de las clases *built-in* de Python es la clase `list`. Si queremos extender la clase `list`, podemos definir una subclase que heredará los métodos de la clase `list` y a su vez tendrá datos y métodos propios:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class ListaDeContactos(list):\n",
    "    \"\"\"\n",
    "    Estamos extendiendo y especializando la clase list estándar. \n",
    "    Tiene todos los métodos de la lista más los definidos por nosotros.\n",
    "    \"\"\"\n",
    "    \n",
    "    # Buscar es un método específico de esta sub-clase\n",
    "    def buscar(self, nombre):\n",
    "        matches = []\n",
    "        for contacto in self:\n",
    "            if nombre in contacto.nombre:\n",
    "                matches.append(contacto)\n",
    "        return matches\n",
    "\n",
    "    \n",
    "class Contacto:\n",
    "    \"\"\"La clase Contacto almacena nombre y correo electrónico.\"\"\"\n",
    "    \n",
    "    def __init__(self, nombre, email):\n",
    "        self.nombre = nombre\n",
    "        self.email = email\n",
    "\n",
    "\n",
    "class Familiar(Contacto):\n",
    "    \"\"\"Familiar es una clase especializada de Contacto que permite incluir el tipo de relación\"\"\"\n",
    "\n",
    "    # Overriding sobre el método __init__()\n",
    "    def __init__(self, nombre, email, relacion):\n",
    "        super().__init__(nombre, email)\n",
    "        self.relacion = relacion"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "contactos_lista = ListaDeContactos()\n",
    "contactos_lista.append(Familiar(nombre=\"Catalina Kendrick\", email=\"cata.k@gmail.com\", relacion=\"prima\"))\n",
    "contactos_lista.append(Contacto(nombre=\"Ami Carol\", email=\"ami.carol@gmail.com\"))\n",
    "contactos_lista.append(Familiar(nombre=\"Carol Fernandez\", email=\"klaus@fernandez.cl\", relacion=\"Hermana\"))\n",
    "contactos_lista.append(Contacto(nombre=\"Fernando Laftrache\", email=\"fernando@laftrache.com\"))\n",
    "\n",
    "personas_llamadas_carol = []\n",
    "for contacto in contactos_lista.buscar(\"Carol\"):\n",
    "       personas_llamadas_carol.append(contacto.nombre)\n",
    "print(personas_llamadas_carol)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "En este ejemplo, la clase `ListaDeContactos` extiende a `list` para agregar un método que busca sobre sí misma (`self`) todos los elementos que coincidan con cierto _string_. Una vez creado un objeto de tipo `ListaDeContactos`, este objeto posee el método `buscar`."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": ".venv",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.12.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
