{
 "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. [Polimorfismo](#Polimorfismo)\n",
    "    1. [Overriding](#Overriding)\n",
    "    2. [Overloading](#Overloading)\n",
    "        1. [Overloading de operadores en Python](#Overloading-de-operadores-en-Python)\n",
    "    3. [`__repr__` vs `__str__`](#__repr__-vs-__str__)\n",
    "2. [*Duck typing*](#Duck-typing)\n",
    "3. [Comentarios finales](#Comentarios-finales)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Polimorfismo\n",
    "\n",
    "El **polimorfismo** se refiere a \"la propiedad por la que es posible enviar mensajes sintácticamente iguales a objetos de tipos distintos\" ([Wikipedia](https://es.wikipedia.org/wiki/Polimorfismo_%28inform%C3%A1tica%29), 2017). Básicamente se trata de utilizar objetos de distinto tipo con la misma *interfaz*. Dos mecanismos típicos para proveer polimorfismo son _overriding_ y _overloading_.\n",
    "\n",
    "- ***Overriding***: ocurre cuando se implementa un método en una subclase que sobreescribe la implementación del mismo método en la super clase\n",
    "   \n",
    "- ***Overloading***: es la capacidad de definir un método con el mismo nombre pero con distinto número y tipo de parámetros. Es la capacidad de una función de ejecutar distintas acciones dependiendo del tipo y número de argumentos que recibe. \n",
    "  \n",
    "Python no soporta _function overloading_. No se puede definir la función más de una vez con distintos tipos y números de parámetros y esperar que ambas definiciones sean consideradas por el programa. Otros lenguajes (Java, C#, C++) sí lo permiten. Sin embargo, se puede \"simular\" usando algunos parámetros con valores por defecto o número de parámetros variables.\n",
    "   "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## *Overriding*\n",
    "Una subclase puede sobreescribir la implementación de los distintos métodos que hereda. A continuación se encuentra un ejemplo en el que se crea una clase superior de nombre `Variable`, la cual almacena un conjunto de datos en el atributo `data`. Se definen tres subclases: `Ingresos`, `Comuna` y `Puesto`. Cada uno, como subclase, posee un atributo `data`, y una implementación distinta del método `representante`. Este método se usa para obtener un valor a partir del conjunto de datos. Algunos ejemplos de valores \"representantes\" pueden ser el promedio, la mediana, o la moda.\n",
    "\n",
    "\n",
    "Se define entonces cómo debe funcionar el método `representante` para cada subclase.\n",
    "\n",
    "- Si los datos son de tipo `Ingresos`, el valor representante es el promedio.\n",
    "- Si los datos son de tipo `Comuna`, el valor representante es la comuna que más se repite. \n",
    "- Si los datos son del tipo `Puesto` de trabajo, entonces el valor representante es el que tiene el puesto más alto según la jerarquía especificada en una lista de categorías.\n",
    "\n",
    "La implementación se ve de la siguiente manera:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import statistics                   ### Este módulo incluye funciones convenientes para calcular valores estadísticas \n",
    "                                    ### como promedio, mediana y moda a partir de listas .\n",
    "\n",
    "class Variable:                     ### Esta clase representa una variable con datos.\n",
    "    def __init__(self, data):\n",
    "        self.data = data\n",
    "\n",
    "    def representante(self):        ### La implementación está vacía para la clase madre.\n",
    "        pass\n",
    "\n",
    "\n",
    "class Ingresos(Variable):           ### Este tipo de variable almacena datos que son números\n",
    "    def representante(self):\n",
    "        return statistics.mean(self.data)\n",
    "\n",
    "\n",
    "class Comunas(Variable):            ### Este tipo de variable almacena datos que son strings (nombres de comunas)\n",
    "    def representante(self):\n",
    "        return statistics.mode(self.data)\n",
    "\n",
    "\n",
    "class Puestos(Variable):            ### Este tipo de variable almacena datos que son strings y representan categorías de cargos.\n",
    "    \n",
    "    # Este es un atributo de la clase Puestos, compartida por todas sus instancias\n",
    "    # Este tipo de atributo se accede con la notación NombreDeLaClase.atributoClase\n",
    "    # Por ejemplo: Puestos.categorias\n",
    "    categorias = ['Alumno en Practica', 'Analista', 'SubGerente', 'Gerente']\n",
    "\n",
    "    def representante(self):\n",
    "        # Paso 1: Transformar la lista en lista de números, donde 0 es alumno en práctica y 3 gerente\n",
    "        puestos = []\n",
    "        for cargo in self.data:\n",
    "            puestos.append(Puestos.categorias.index(cargo))\n",
    "        # Paso 2: Vemos cuál es el máximo\n",
    "        maximo = max(puestos)\n",
    "        # Paso 3: Retornar cargo asociado\n",
    "        return Puestos.categorias[maximo]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Vemos que cada subclase define su propia implementación del método `representante`. Cuando se invoca a un método sobre un tipo de datos, primero se busca el método en la definición del tipo de datos correspondiente. Por ejemplo, si estamos en un objeto de tipo `Comunas`, se invoca el método `representante` definido en la clase `Comunas`. Si no se llegara a encontrar el método en la definición de una clase, entonces se busca si está implementado en la clase superior."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "lista_pesos = Ingresos([50, 80, 90, 150, 45, 65, 78, 89, 59, 77, 90])\n",
    "lista_comunas = Comunas(['Providencia', 'Macul' , 'La Reina' ,'Santiago', 'Providencia', 'Puente Alto',\n",
    "                        'Macul', 'Santiago', 'Santiago'])\n",
    "lista_puestos = Puestos(['SubGerente', 'Analista','SubGerente','Analista','Alumno en Practica',\n",
    "                        'Alumno en Practica'])\n",
    "\n",
    "print(lista_pesos.representante())\n",
    "print(lista_comunas.representante())\n",
    "print(lista_puestos.representante())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Podemos ver que, si bien las clases `Ingresos`, `Comunas` y `Puestos` heredan de la misma clase, a pesar de tener distintos tipos de datos, para cada una de ellas podemos llamar de la misma manera a su método `representante` y, de acuerdo a la clase que corresponda, se llama a la versión correcta del método.\n",
    "\n",
    "Asi, *overriding* un ejemplo de polimorfismo, ya que se invoca el mismo método sobre objetos de distinto tipo, y cada uno lo interpreta de acuerdo a su propia definición."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## *Overloading*\n",
    "\n",
    "A diferencia de otros lenguajes, como C++ o Java, Python no soporta _function overloading_, es decir, no es posible definir dos veces la misma función con diferente tipo o número de argumentos. Es por esto que el siguiente código no se podrá ejecutar:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def funcion(arg):\n",
    "    print(arg)\n",
    "\n",
    "\n",
    "def funcion(arg1, arg2):\n",
    "    print(arg1, arg2)\n",
    "    \n",
    "funcion('este')\n",
    "funcion('codigo', 'fallará')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Profundizando un poco más, al leer el error entregado se hace evidente que la definición que se está considerando es la segunda, pues la ejecución de `funcion('este')` falla al dar un solo argumento en lugar de dos, es decir, Python está considerando solamente la definición más nueva. Cada vez que Python ve una definición nueva de una función (o una variable, o una clase), se queda con la más reciente.\n",
    "\n",
    "A pesar de lo anterior, Python sí permite un tipo de _overloading_, el _overloading_ de sus operadores *built-in*."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### *Overloading* de operadores en Python\n",
    "Existen muchos operadores en Python que funcionan para varias de las clases *built-in*. Por ejemplo, el operador \"+\" puede sumar dos números, concatenar dos strings, mezclar dos listas, etc. (dependiendo de la clase con la que estemos trabajando). Esto es un ejemplo de `overloading`; el mismo operador funciona de distinta manera de acuerdo al tipo de los argumentos que recibe."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "a = [1, 2, 3, 4]\n",
    "b = [5, 6, 7, 8]\n",
    "print(a + b)\n",
    "c = \"Hola\"\n",
    "d = \" Mundo\"\n",
    "print(c + d)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Además, Python nos permite personalizar el método `__add__` para que el operador \"+\" funcione en algún tipo de clase específica que necesitemos. Por ejemplo, supongamos una clase que representa un carro de compra:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Carro:\n",
    "\n",
    "    def __init__(self, pan, leche, agua):\n",
    "        self.pan = pan\n",
    "        self.leche = leche\n",
    "        self.agua = agua\n",
    "    \n",
    "    def __add__(self, otro):                    ## Este método nos permitirá \"sumar\" dos objetos de clase Carro\n",
    "        \n",
    "        suma_pan = self.pan + otro.pan\n",
    "        suma_leche = self.leche + otro.leche\n",
    "        suma_agua = self.agua + otro.agua\n",
    "            \n",
    "        return Carro(suma_pan, suma_leche, suma_agua)\n",
    "    \n",
    "    def __str__(self):\n",
    "        return f\"Pan:{self.pan}, Leche:{self.leche}, Agua:{self.agua}\""
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "carro_1 = Carro(1, 2, 3)\n",
    "carro_2 = Carro(3, 4, 5)\n",
    "carro_sumado = carro_1 + carro_2\n",
    "print(carro_sumado)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "De la misma forma, podemos personalizar la mayoría de los operadores. Por ejemplo, para personalizar el operador \"menor que\" implementamos `__lt__` (del inglés *less than*):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import math\n",
    "\n",
    "class Vector: \n",
    "    \"\"\"Vector desde el origen\"\"\"\n",
    "    def __init__(self, x, y): \n",
    "        self.x = x \n",
    "        self.y = y\n",
    "        \n",
    "    def magnitud(self):\n",
    "        return math.sqrt(self.x ** 2 + self.y ** 2)\n",
    "    \n",
    "    def __lt__(self, otro_punto):                        ### Este método nos permite comparar si un Vector es estrictamente menor que otro\n",
    "        return self.magnitud() < otro_punto.magnitud()\n",
    "\n",
    "v1 = Vector(2,4)\n",
    "v2 = Vector(8,3)\n",
    "print(v1 < v2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "De la misma manera existen métodos que permiten hacer *overloading* de operadores como \"menor o igual que\", `__le__`, \"mayor que\", `__gt__`, \"mayor o igual que\", `__ge__`, \"igual que\", `__eq__`, o \"distinto\", `__ne__`. Hay otros más."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### *Overloading* en otros lenguajes\n",
    "\n",
    "A diferencia de Python, existen otros lenguajes que sí permiten la técnica de *overloading*. Esto lo logran gracias a que es posible definir dos (o más) funciones (o métodos) que posean el mismo nombre, pero varían tanto en la cantidad de parámetros que poseen o en los **tipos** de dichos parámetros.\n",
    "\n",
    "Aunque en este curso nos enfocamos solamente en Python (este es el único lenguaje en el que programarán), es bueno poder realizar una comparación con otro lenguaje para entender de mejor manera la técnica de *overloading*. Es por esto que a continuación podemos ver un ejemplo de *function overloading* en C++:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```c++\n",
    "\n",
    "#include <iostream>\n",
    "using namespace std;\n",
    "\n",
    "// Creamos una clase\n",
    "class printData {\n",
    "\n",
    "   // Definimos los métodos públicos que tendrá la clase,\n",
    "   // esto debido a la técnica de encapsulamiento (concepto que veremos más adelante)\n",
    "   public:\n",
    "   \n",
    "      // Creamos el método print el cual   \n",
    "      // recibe un parámetro int\n",
    "      void print(int i) {\n",
    "        cout << \"Printing int: \" << i << endl;\n",
    "      }\n",
    "      \n",
    "      //  Creamos otro método print, el cual   \n",
    "      // recibe ahora un parámetro float\n",
    "      void print(double  f) {\n",
    "        cout << \"Printing float: \" << f << endl;\n",
    "      }\n",
    "      \n",
    "      //  Creamos otro método print, el cual   \n",
    "      // recibe ahora un parámetro bool\n",
    "      void print(bool b) {\n",
    "        cout << \"Printing bool: \" << b << endl;\n",
    "      }\n",
    "};\n",
    "\n",
    "// En c++ la lógica del programa debe ser implementada\n",
    "// dentro de la funcion main, ya que este es el que\n",
    "// será ejecutado cuando se ejecute el programa\n",
    "int main(void) {\n",
    "    \n",
    "   // Creamos una instancia de la clase printData\n",
    "   printData pd;\n",
    " \n",
    "   // Llamamos al método print para imprimir un int\n",
    "   pd.print(5);\n",
    "   \n",
    "   // Llamamos al método print para imprimir un float\n",
    "   pd.print(500.263);\n",
    "   \n",
    "   // Llamamos al método print para imprimir un booleano\n",
    "   pd.print(true);\n",
    "  \n",
    "   // Finalmente retornamos un 0, ya que es necesario en este lenguaje\n",
    "   return 0;\n",
    "}\n",
    "\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cuando el código anterior sea ejecutado, será posible obtener los siguientes resultados"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```c++\n",
    "\n",
    "Printing int: 5\n",
    "Printing float: 500.263\n",
    "Printing bool: 1\n",
    "\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Por lo tanto podemos observar que en este lenguaje es posible que exista más de un método con el mismo nombre dentro de una clase"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## `__repr__` vs `__str__`"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Los métodos `__repr__` y `__str__` permiten obtener una representación en texto de nuestro objeto. Estos métodos deben retornar un *string*, el que podrá ser usado por la función `print`. Si se implementan ambos, `print` utiliza `__str__`.\n",
    "\n",
    "La diferencia entre  `__str__` y `__repr__` es sutil. Si bien ambos devuelven una representación del objeto en forma de *string*, cada representación persigue un objetivo distinto. \n",
    "* **`__str__`** busca devolver una representación legible (*human-readable*) del objeto. Es como si un usuario del programa quisiera leer esa información, y por eso se usa para `print`. \n",
    "* **`__repr__`** tiene por objetivo ofrecer una representación completa y sin ambigüedades del objeto. Es como si un desarrollador del programa quisiera leer esa información.\n",
    "\n",
    "El siguiente ejemplo define una clase `Fraccion`, con una implementación para `__repr__` y una para `__str__`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Fraccion:\n",
    "    def __init__(self, numerador, denominador): \n",
    "        self.numerador = numerador \n",
    "        self.denominador = denominador\n",
    "        \n",
    "    def __repr__(self):\n",
    "        return f\"Fraccion({self.numerador}, {self.denominador})\"\n",
    "    \n",
    "    def __str__(self):\n",
    "        return f\"{self.numerador} / {self.denominador}\"\n",
    "    \n",
    "frac = Fraccion(3, 4)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "repr(frac)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "str(frac)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(frac)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Si no implementamos el método `__str__`, `print` va a imprimir el *string* que retorna la función `__repr__`"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Fraccion: \n",
    "    def __init__(self, numerador, denominador): \n",
    "        self.numerador = numerador \n",
    "        self.denominador = denominador\n",
    "        \n",
    "    def __repr__(self):\n",
    "        return f\"Fraccion({self.numerador}, {self.denominador})\"\n",
    "    \n",
    "frac = Fraccion(3, 4)\n",
    "print(frac)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`__repr__` es interesante porque puede ser usado para mostrar los elementos de una lista. Por ejemplo, si tenemos solamente una implementación de `__str__` y queremos imprimir una lista de objetos `Fraccion`, pasa esto:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Fraccion: \n",
    "    def __init__(self, numerador, denominador): \n",
    "        self.numerador = numerador \n",
    "        self.denominador = denominador\n",
    "        \n",
    "    def __str__(self):\n",
    "        return f\"{self.numerador} / {self.denominador}\"\n",
    "    \n",
    "frac1 = Fraccion(3, 4)\n",
    "frac2 = Fraccion(5, 8)\n",
    "frac3 = Fraccion(2, 7)\n",
    "lista = [frac1, frac2, frac3]\n",
    "print(lista)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Pero si tenemos la implementación de `__repr__`, la función `print` la utiliza para mostrar la representación de cada objeto."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Fraccion:\n",
    "    def __init__(self, numerador, denominador): \n",
    "        self.numerador = numerador \n",
    "        self.denominador = denominador\n",
    "        \n",
    "    def __repr__(self):\n",
    "        return f\"Fraccion({self.numerador}, {self.denominador})\"\n",
    "    \n",
    "    def __str__(self):\n",
    "        return f\"{self.numerador} / {self.denominador}\"\n",
    "    \n",
    "frac1 = Fraccion(3, 4)\n",
    "frac2 = Fraccion(5, 8)\n",
    "frac3 = Fraccion(2, 7)\n",
    "lista = [frac1, frac2, frac3]\n",
    "print(lista)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# *Duck typing*\n",
    "\n",
    "> \"If it walks like a duck and quacks like a duck then it is a duck\" \n",
    "(no importa de qué tipo sea un objeto mientras contenga la acción)\n",
    " \n",
    "*Duck typing* es una característica de algunos lenguajes que hace que el polimorfismo sea menos atractivo, ya que el lenguaje por sí sólo es capaz de generar comportamiento polimórfico sin la necesidad de implementar el polimorfismo a través de la herencia. \n",
    "\n",
    "Consideremos las siguientes clases:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Pato:\n",
    "    def gritar(self):\n",
    "        print(\"Quack!\")\n",
    "        \n",
    "    def caminar(self):\n",
    "        print(\"Caminando como un pato\")        \n",
    "    \n",
    "class Persona:\n",
    "    def gritar(self):\n",
    "        print(\"¡Ahhh!\")\n",
    "        \n",
    "    def caminar(self):\n",
    "        print(\"Caminando como un humano\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Es claro que si creamos un objeto de tipo `Pato` y un objeto de tipo `Persona`, se llamará a los métodos `gritar` y `caminar` que correspondan a la clase."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "donald = Pato()\n",
    "patricio = Persona()\n",
    "donald.gritar()\n",
    "patricio.gritar()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Pero si escribimos una función que recibe un argumento, no sabemos, al momento de programarlo, qué tipo de dato recibirá este objeto. Y no necesitamos saberlo, pues el mecanismo de *duck typing* determinará al momento de ejecutar, qué método se invocará, de acuerdo con el tipo de dato."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def activar(pato):  # Esto, en otro tipo de lenguaje, obligaría a que pato sea del tipo \"Pato\", por lo tanto\n",
    "    pato.gritar()   # la función activar no podría ser llamada con un argumento tipo \"Persona\"\n",
    "    pato.caminar()\n",
    "\n",
    "donald = Pato()\n",
    "elon = Persona()\n",
    "activar(donald)\n",
    "activar(elon)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "En este ejemplo hay dos clases distintas, `Pato` y `Persona`, sin ninguna relación de herencia entre ellas. Cada una tiene implementados los métodos `gritar` y `caminar`. La función `activar` recibe un argumento de nombre `pato`, pero no sabe (ni le interesa) si es un objeto de tipo `Pato` o `Persona`; simplemente llama a los métodos `gritar` y `caminar`, y en ese momento se determina si la clase a la cual pertenece el argumento `Pato` contiene una implementación del método que se necesita.\n",
    "\n",
    "Este comportamiento puede parecer obvio, sobre todo para quienes solo han programado en Python, sin embargo en otros lenguajes de programación, como C, C++, Java ó C#, se obliga a que los argumentos tengan un tipo de dato definido (lenguajes con sistema de tipos estáticos), por lo tanto este mecanismo no funcionaría. \n",
    "\n",
    "Lenguajes como Python utilizan un sistema de tipos dinámicos, lo que permite que el tipo de una variable se determine al momento de ejecutar el código (y no al compilarlo ni al escribirlo). Gracias a esto, la función `activar` puede recibir cualquier tipo de argumentos. Sin embargo, si recibe un argumento que no posee una implementación para `gritar` o para `caminar`, se producirá un error."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Comentarios finales\n",
    "\n",
    "Existen muchas opiniones acerca de la relación entre polimorfismo, herencia y *duck typing* ([1](https://softwareengineering.stackexchange.com/questions/121778/is-duck-typing-a-subset-of-polymorphism), [2](https://stackoverflow.com/questions/11502433/what-is-the-difference-between-polymorphism-and-duck-typing), [3](https://www.reddit.com/r/learnprogramming/comments/2r30c0/is_ducktyping_and_advanced_form_of_polymorphism/) y otras). Lo importante para este curso es que entiendas cómo se implementan estos tres conceptos en Python. Si tienes  dudas, te invitamos a crear una issue en el foro del curso 😃."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "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.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
