5. Programación Orientada a Objetos en Python#

Como ya hemos visto, Python no es un lenguaje orientado a objetos (OO) puro. Podemos programar scripts en los cuales no es necesario utilizar el paradigma explicitamente. Aunque los tipos de datos, estructuras e incluso las funciones son objetos, no es necesario implementar una clase principal o métodos miembro para resolver muchos problemas de manera efectiva.

Esta flexibilidad puede dar la impresión de que la orientación a objetos fue incorporada como un agregado tardío al lenguaje. En parte, esta percepción surge de algunas decisiones sintácticas y conceptuales, como la definición explícita de constructores, el paso manual de la referencia self en los métodos de instancia, o la ausencia de mecanismos formales como interfaces obligatorias. Sin embargo, más que un parche, estas características reflejan una decisión de diseño deliberada: priorizar la simplicidad, la legibilidad y la expresividad del lenguaje por encima de la rigidez paradigmática.

Un aspecto importante de algunos lenguajes de Programción Orientada a Objetos como Java o C** es el tipado estrícto y jerarquías formales. Esto ocasiona que tengamos que utilizar mecanismos elaborados para resolver problemas comunes, como el polimorfismo, el uso de interfaces o plantillas (genéricos). Estos mecanismos aportan seguridad y claridad en software a gran escala, pero también incrementan la verbosidad del código. Python como lenguaje dinámico, nos permite resolver muchos de estos problemas de forma más directa y concisa.

Por esta razón, creo que Python puede no ser el mejor lenguaje para aprender Programación Orientada a Objetos (POO) en su forma más clásica o académica, ya que muchos de los conceptos o elementos de programación del paradigma no son obligatorios ni se manifiestas de manera explicita. Por otro lado, el hecho de que el paradigma sea opcional convierte a Python en un excelente lenguaje para comparar paradigmas y entender cuándo resulta apropiado utilizar programación procedural, funcional u orientada a objetos.

Entonces, creo que esta sección no debería ser tan extensa como en los capítulos de libros dedicados a lenguajes puramente OO. El enfoque estará en ver y enteder las diferencias entre paradigmas en términos de programación.

Nota

A lo largo del libro veremos que si hay un estilo de programación en Python, pero al igual que el lenguaje es algo libre e híbrido.

5.1. Ámbitos y espacios de nombres en Python#

La documentación oficial de Python aborda este importante tema en la sección de definición de clases. En este libro vamos a seguir la misma estructura ya que es una manera interesante de abordar las diferencias entre los paradigmas procedural/funcional y el orientado a objetos. Entender muy bien estos conceptos nos hará mejores programadores independientemente del paradigma que utilicemos. Primero es importante definir que es un espacio de nombres y después el ámbito de visibilidad que hay entre ellos.

Iniciemos una nueva sesión del interprete y como primera instrucción vamos a ejecutar el método incluido de fábrica dir(). Cuando invocamos esta función sin argumentos, nos regresa una lista de cadenas que representan los nombres definidos en el ámbito actual:

>>> dir()
['__annotations__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__']

Estos son los nombres disponibles en ese punto del programa. Entre estos nombres encontramos: variables internas, módulos cargados automáticamente y otros elementos del contexto de ejecución. Es comprender esta idea fundamental:

Nota

En todo momento, nuestro programa opera dentro de un ámbito limitado de nombres.

La función dir() nos permite asomarnos a ese ámbito y conocer qué identificadores están definidos y disponibles en este momento.

Vamos a crear algunos nombres adicionales en este ámbito.

>>> entero = 1234
>>> nombre = 'Ana'
Como es de esperarse estos nombres se agregan al ámbito actual:
>>> dir()
['__annotations__', '__builtins__', 'entero',  'nombre']
# El resultado se recortó para que no ocupe tanto espacio.

Incluso podemos agregar una función:

>>> def genera_correo(n):
...     dominio = 'gmail.com'
...     print(dir())
...     return f'{n}@{dominio}'
...
>>> dir()
['__annotations__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'entero', 'genera_correo', 'nombre']

Esta función incluye la variable local dominio y el argumento n. Es importante notar que estos dos nombres, no están disponibles en el ámbito actual.

Como demostración en la función genera_correo(n) se imprime el resultado de dir():

>>> genera_correo('juan')
['dominio', 'n']
'[email protected]

Ahora, como es de esperarse, solo se imprimen los nombres ['dominio', 'n']. Estos dos nombres están ocultos, no se pueden modificar ni leer desde fuera. Solo las instrucciones dentro del ámbito pueden hacerlo. Este es un principio importante de la programación estructurada y la programación orientada a objetos:

Ocultación de la información (information hiding)#

Es un principio por el cual se separan los detalles de implementación de los detalles de uso, de modo que los componentes de un programa solo acceden a lo que necesitan saber, y no a los mecanismos internos de otros componentes

En este ejemplo, si necesitamos crear un correo electrónico, solo podemos ver el nombre de la función y su parámetro. Pero no tenemos control sobre lo que sucede dentro, los detalles de implementación. Esto puede permitir a los programadores mejorar la implementación interna sin afectar al resto del programa. Por ejemplo, vamos a mejorar un poco la implementación:

>>> def genera_correo(n, dominio='gmail.com'):
...    return f'{n}@{dominio}'
...

Ahora podemos enviar como segundo parámetro el dominio del correo y por defecto se pasa 'gmail.com' de esta manera, algunas partes del programa seguiran llamando a la función de la manera anterior sin que les afecte el nuevo cambio. Y en otras partes se puede utilizar de la nueva manera.

>>> genera_correo('juan')
'[email protected]'
>>> genera_correo('juan','hotmail.com')
'[email protected]'

Al ocultar los detalles de implementación evitamos que los usuarios de nuestras funciones dependan de las decisiones que tomemos internamente y que no les afecten los cambios.

En el caso de variables locales como las de la función anterior. Se crean al momento de llamar a la función y se eliminan cuando la función regresa o se lanza alguna excepción. También hay ambitos de nombres que tienen una vida más duradera, por ejemplo, los nombres de fabrica (builtins), se crean al iniciar el intérprete y nunca se destruyen.

Visibilidad y Encapsulamiento en un Módulo#

Este concepto de ocultar o encapsular la información es importante en la programación estructurada al momento de descomponer la funcionalidad de nuestros programas. Podemos seguir un diseño descendente (top-down) dividiendo el problema que atacamos en diversos subproblemas y estos a su vez en otros más pequeños. En Python podemos organizar nuestro código en módulos (ver sección de modulos) en los cuales pueden contener funciones las cuales a su vez pueden definir funciones internas. El punto clave es que las funciones internas no son accesibles desde fuera de su función contenedora. Sin embargo, las funciones anidadas tienen acceso al contexto de las funciones que las contienen. Esta estructura se muestra en la siguiente figura:

Visibilidad y Encapsulamiento.

En el lado izquierdo podemos observar la estructura jerárquica dentro de un módulo (azul claro) en Python esto puede ser un archivo .py. Dentro del módulo hay funciones principales: en este caso la Función A y Función B (rosadas). A su vez cada función principal puede contener internamente otras funciones (lavanda):

  • La Función A contiene las funciones a y a2

  • La Función B contiene a la función b

Este es un modelo anidado de funciones, donde las funciones internas solo son visibles desde las funciones que las contienen. Del lado derecho se ve en detalle este concepto de visibilidad:

El flujo está hacia abajo lo vemos como una «caja negra»: los niveles superiores no conocen los detalles internos de los niveles inferiores (encapsulamiento). Ahora, las flechas discontínuas nos muestran la visibilidad permitida: las funciones internas (a, a2, b) pueden «ver» o acceder a los nombres locales de las funciones que las contienen (A, B), y estas a su vez a los nombres de los módulos.

Veamos un ejemplo. Al iniciar en intérprete estamos al nível de un módulo. Vamos a definir un nombre a este nivel:

>>> x = 123

Ya ahora una función a este nivel, la cual internamente tiene dos funciones:

>>> def Función_A():
...     A = 333
...     def función_a():
...         print(x)
...         print(A)
...     def función_a2():
...         x = 1
...     función_a()
...     función_a2()

La función incluye un nombre A local y fíjate que dentre de la función_a se leen los nombres x y A de los ámbitos externor. Esta es la visibilidad hacia arriba. Ahora también en la función_b intentamos modificar a la variable externa x, esto realmente crea una variable local x y no se modifica la externa.

Una vez definidas las funciones, podemos ver que no es posible llamar a la función interna función_a():

>>> función_a()
Traceback (most recent call last):
  File "<python-input-4>", line 1, in <module>
    función_a()
    ^^^^^^^^^
NameError: name 'función_a' is not defined. Did you mean: 'Función_A'?

Lo podemos hacer llamando a la función que esta directamente en este ámbito, la Función_A() internamente llama a sus funciones y solo vemos el resultado:

>>> Función_A()
123
333

Como vemos la función x no fue modificada.

>>> x
123

Para modificar o crear un objeto en el ámbito del módulo podemos hacerlo utilizando la palabra clave global:

>>> def Función_A():
...     A = 333
...     def función_a():
...         print(x)
...         print(A)
...     def función_a2():
...         global x
...         x = 1
...     función_a()
...     función_a2()

>>> Función_A()
123
333

>>> x
1

Para cambiar un nombre que esta definido en un ámbito externo (más arriba en la jerarquía), podemos utilizar la palabra reservada nonlocal de manera similar a global.

En resúmen, son importantes estos tres conceptos:

  • Encapsulamiento: Las funciones internas no son accesibles desde fuera de su función contenedora.

  • Alcance léxico: Las funciones anidadas tienen acceso al contexto de las funciones que las contienen.

  • Diseño modular: El módulo oculta los detalles internos a quien lo usa; solo expone una interfaz (las funciones principales).

Estos conceptos se utilizan de nuevo en la programación orientada a objetos.

5.2. Clases#

Tomando como base el concepto de encapsulamiento, para hacer un cambio de paradigma. En un módulo podemos tener un ambito que incluye datos (nombres) y funciones para encapsular cierta funcionalidad. En orientación a objetos, un clase encapsula los atributos (en lugar de datos) y métodos (en lugar de funciones) que definen a un nuevo tipo de objeto. Cada objeto lo podríamos ver como un módulo independiente, con sus propios datos y funcionalidad interna. Aunque conceptualmente el encapsulamiento lo hemos visto como ocultar información y funciones, en realidad esto se hace de manera selectiva, cuándo definimos un módulo podemos especificar que datos y funciones serán visibles a otros módulos. Lo mismo sucede en la programación orientada a objetos. Veamos un ejemplo:

>>> class Persona:
...     clase = 'Persona'
...     def __init__(self, nombre, apellido):
...         self.nombre = nombre
...         self.apellido = apellido
...     def get_nombre_completo(self):
...         return f'{self.nombre} {self.apellido}'
...     def saluda(self):
...         print(f'Hola soy {self.get_nombre_completo()}')
...

Vemos como la definición es básicamente un bloque dónde definimos métodos y atributos. Como cada instancia (objeto) de esta clase tendrá sus propios atributos y todas las instancias van a compartir los métodos definidos en su clase. Debemos utilizar la referencia self para identificar al objeto particular que estamos utilizando.

>>> ana = Persona('Ana', 'Lee')
>>> ana.saluda()
Hola soy Ana Lee
>>> tom = Persona('Tom', 'Pit')
>>> tom.saluda()
Hola soy Tom Pit
>>> Persona.clase
'Persona'

En este código creamos dos instancias de la clase Persona, cada objeto se crea en una localidad independiente de memoria y la podemos referenciar utilizando self. Esto lo debemos de especificar explicitamente al definir la clase para decir que estos son atributos y métodos de instancias. En el caso del atributo Persona.clase este no tiene self porque se trata de un atributo de la clase. Cuando ejecutamos el método de instancia saluda, por ejemplo ana.saluda() no es necesario enviar la referencia self, esto se hace implicitamente.

El uso de :python:`self` al definir una clase.

Nota

Es importante agregar la referencia self en todos lados. Por ejemplo, self.get_nombre_completo(), aunque esto no es necesario en otros lenguajes, uno de los principios básicos de Python es «Explicito es preferible a implícito». Aunque no solo es filosofía, además de hacer explícito que nombres son atributos, evita que caigamos en ambigüedad con las varibles locales.

Métodos Especiales#

El método __init__ es un «método especial», a estos métodos comúnmente les llamamos «métodos mágicos» ya que están encerrados entre dobles guiones bajos (underscores) __mágico__(). Si vienes de un lenguaje orientado a objetos puro como C# o Java, sabes que todos los objetos heredan cierta funcionalidad común desde una clase base universal como Object. Como todos los objetos hereda de Object, todos los objetos incluyen métodos como ToString() o Equals() entre otros. Estos métodos se ejecutan de manera automática cuando realizamos ciertas operaciones sobre los objetos, por ejemplo, cuándo imprimimos un objeto automáticamente se ejecuta ToString() y se imprime el texto que regresa. Normalmente redefinimos estos métodos utilizando algo como override, para cambiar el comportamiento incluido «por defecto».

Nota

Explicito es mejor que implícito

En Python estos métodos se nombran de una manera especial (entre doble guión __) para indicar explícitamente que éstos métodos no se deben invocar directamente, el intérprete lo hará cuando se requiera.

Los métodos especiales se invocan al realizar operaciones como: print(objeto) esto llamaría a objeto.__str__() en caso de que no se haya redefinido intenta llamar al método objeto.__repr__. El método __str__ debería mostrar información «legible para humanos» y por otro lado __repr__ es una representación para programadores con la itención de que sirva para depurar el código. Vamos a modificar la clase anterior para que podamos imprimir los objetos diractamente:

class Persona:
   clase = 'Persona'  # variable de clase, se comparte por todas las instancias
   def __init__(self, nombre, apellido):
      self.nombre = nombre  # variable de instancia, cada instancia tiene su propia
      self.apellido = apellido
   def __str__(self):
      return f'{self.nombre} {self.apellido}'
   def saluda(self):
      print(f'Hola soy {self}')

Como ya redefinimos el método mágico __str__(), podemos hacer los siguente:

>>> ana = Persona('Ana', 'Lee')
>>> ana
<__main__.Persona object at 0x000001B61F3E1400>
>>> print(ana)
Ana Lee
>>> ana.__str__()
'Ana Lee'

Vemos que si es posible ejecutar directamente al método especial, pero al escribirlo sabemos que no es lo recomendable. Otro detalle, si ponemos el nombre de un objeto y damos «Enter» se imprimen los datos de ese objeto. En el caso del ejemplo se imprime su clase y la dirección en memoria. Si hubieramos redefinido el método __repr__(), se imprimiría lo regresara el método.

En el caso de object.__init__(), este método se invoca justo después de terminar de crear la instancia de una clase. Es el constructor en otros lenguajes.

Una diferencia que notamos en Python, es que no definimos los atributos de tipo instancia directamente en la clase, lo hacemos en el constructor. Esto es porque los nombres se definen al mismo tiempo que los atamos a un objeto. En C#, por ejemplo, debemos indicar para cada atributo, el nivel de visibilidad y si es un atributo estático. En Python, los atributos estáticos los definimos directemante en la clase, y los de instancia en el constructor.

El ejemplo, en C# se podría implementar así:

class Persona {
 public static Clase = "Persona",
 public string Nombre;
 public string Apellido;
 public Persona(string Nombre, string Apellido) {
      this.Nombre = Nombre;
      this.Apellido = Apellido;
 }
 public override string ToString() {
     return $"{Nombre} {Apellido}";
 }
 public void Saluda() {
     Console.WriteLine($"Hola, soy {this}")
 }
}

Objetos#

Aunque en programación orientada a objetos los términos objeto e instancia los utilizamos como sinónimos, en Python hay una diferencia importante:

Objeto

Todos las entidades en Python son objetos, incluyendo las listas, cadenas de texto, funciones, números, e incluso las clases. El término «objeto» se utiliza porque se enfatiza que es una unidad de datos con tipo, identidad, y estado. Estas también son propiedades de las instancias.

Instancia

Es un objeto que creamos a partir de una clase. Las instancias tienen una relación con la clase que las originó.

Veamos esto en código. Vamos a crear dos objetos, una cadena, una lista y una instancia de la clase Persona que definimos anteriormente:

>>> x = 10
>>> nombre = 'ana'
>>> ana = Persona('Ana', 'Lee')
  • Identidad Los objetos tienen identidad, podemos identificarlos de manera única y su identidad no cambia durante la vida del objeto. Podemos pensar como la dirección única que tiene el objeto en la memoria:

>>> id(x)
140728828626120
>>> id(nombre)
1557033019232
>>> id(ana)
1557033194496
  • Tipo Todos los objetos tienen tipo, el tipo define las operaciones que un objeto puede realizar y los atributos que tiene. Decimos que ana es una instancia de Persona una clase que nosotros definimos.

>>> type(x)
<class 'int'>
>>> type(nombre)
<class 'str'>
>>> type(ana)
<class '__main__.Persona'>
  • Estado Los objetos tienen un valor (o valores) específicos los cuales determinan su estado. El comportamiento de un objeto puede modificar su estado, y las operaciones que puede realizar en un momento dado puede depender del estado actual del objeto.

>>> x
10
>>> print(ana)
Ana Lee
  • Comportamiento Los objetos hacen cosas, fíjate que dependiendo de su estado el comportamiento puede ser distinto.

>>> x.__str__()
'10'
>>> nombre.upper()
'ANA'
>>> ana.saluda()
Hola soy Ana Lee

La clase Persona que definimos, también es un objeto:

>>> id(Persona)
1557028855728
>>> type(Persona)
<class 'type'>
>>> Persona.clase
'Persona'

Incluso podemos utilizar los métodos mágicos del objeto Persona para crear una nueva instancia:

>>> tom = Persona.__new__(Persona)
>>> tom.__init__('Tom', 'Pit')
>>> tom.saluda()
Hola soy Tom Pit
>>> type(tom)
<class '__main__.Persona'>

De nuevo, estos métodos se llaman automáticamente por el intérprete cuando «instanciamos» un objeto:

>>> tom = Persona('Tom', 'Pit')

Objetos dinámicos#

Aunque esto lo debemos hacer con cuidado y no es recomendable ya que viola el principio de uniformidad. Los objetos son dinámicos, así que les podemos agregar individualmente nuevos atributos y métodos una vez creados:

>>> tom.edad_actual = 27

Para crear un método debemos importar la librería types:

>>> import types
>>> def saluda_nuevo(self):
...     print(f'Hola soy {self} tengo {self.edad_actual}')
...
>>> tom.saluda = types.MethodType(saluda_nuevo, tom)
>>> ana.saluda()
Hola soy Ana Lee
>>> tom.saluda()
Hola soy Tom Pit tengo 27

También podríamos modificar una clase ya que se haya creado, o tener un método que genere clases. Estos temas los dejamos para después. Lo importante en este momento, es darnos cuenta de la flexibilidad de Python.

Advertencia

Aunque los objetos dinámicos nos ofrecen ventajas y libertades, esto rompe expectativas y dificulta las pruebas y el mantenimiento.

:python:`hasattr(object, attr)`

Podemos revisar si un objeto tiene un atributo con hasattr():

>>> hasattr(ana,'edad_actual')
False
>>> hasattr(tom,'edad_actual')
True

:python:`getattr(object, attr)`

También podemos leer un atributo de un objeto de manera dinámica:

>>> getattr(tom,'edad_actual')
27
>>> getattr(tom,'saluda')()
Hola soy Tom Pit tengo 27

Por último, los objetos pueden ver a su clase:

>>> ana.__class__
<class '__main__.Persona'>
>>> ana.__class__.clase
'Persona'

Si queremos referirnos a los atributos de la clase al estarla definiendo utilizamos self:

class Persona:
   clase = 'Persona'
   def __init__(self, nombre, apellido):
      self.nombre = nombre
      self.apellido = apellido
   def __str__(self):
      return f'{self.nombre} {self.apellido}'
   def saluda(self):
      print(f'Hola soy {self}, soy una instancia de: {self.__class__.clase}')

En este ejemplo, utilizamos un atributo clase como ejemplo. Pero como vimos, podríamos saber el nombre de la clase sin necesidad de este atributo.

Miembros privados en Python#

Los mecanismos de introspección que ofrece Python —como la posibilidad de consultar los atributos de un objeto o transformarlo en un diccionario— contrastan con el principio de ocultar los datos internos de una instancia.

En Python, las variables de instancia privadas no se implementan de forma estricta. En su lugar, se utiliza una convención: se antepone un guion bajo (_) al nombre de los atributos, funciones o métodos que se consideran parte interna de la implementación, como en self._nombre.

Aunque estos miembros no están realmente protegidos contra el acceso externo, esta convención señala que no deberían ser utilizados fuera del contexto de la clase, ya que forman parte de su estructura interna y podrían cambiar sin previo aviso.

Nota

El diseño del lenguaje Python considera más importante la transparencia y la flexibilidad (introspección, reflexión) que el encapsulamiento estricto.

Se dice que el creador de Python Guido van Rossum, dijo la frase: “We are all consenting adults here.” («Aquí todos somos adultos responsables.») defendiendo sus decisiones de diseño.

Herencia#

Python implementa la herencia múltiple. La sintáxis para derivar una clase es la siguiente:

>>> class Estudiante(Persona):
...     def __init__(self, nombre, apellido, especialidad):
...         super().__init__(nombre, apellido)
...         self.especialidad = especialidad
...     def saluda(self):
...         print(f'Hola, estudio {self.especialidad} y me llamo {self}')
...
>>> ana = Estudiante('Ana', 'Lee', 'Arquitectura')
>>> ana.saluda()
Hola, estudio Arquitectura y me llamo Ana Lee

En este ejemplo la clase Estudiante hereda de la clase Persona y redefine el método saluda() definido en la clase base. El método super() utilizado aquí sin parámetros y en una herencia simple, buscaría el método subiendo la jerarquía de clasees. Si enviamos como parámetro un tipo, la busqueda en la jerarquía de clases empezaría a partir de esa clase. Por ejemplo, para la jerarquía: Estudiante_Temporal -> Estudiante -> Persona -> object ``, una llamada :python:`super(Persona)` empezaría la búsqueda del método a partir de ``Persona -> object.

Herencia Múltiple#

Para este ejemplo vamos a implementar la siguiente herencia de clases:

Visibilidad y Encapsulamiento.

Lo haremos en un script que llamaremos herencia.py:

herencia.py.#
class Persona:
   def __init__(self, nombre, apellido, **kwargs):
      self.nombre = nombre
      self.apellido = apellido
      super().__init__(**kwargs)

   def saluda(self):
      print(f"Hola, soy {self.nombre} {self.apellido}")

class Estudiante(Persona):
   def __init__(self, especialidad, **kwargs):
      self.especialidad = especialidad
      super().__init__(**kwargs)

   def saluda(self):
      super().saluda()
      print(f"Estudio {self.especialidad}")

class Empleado(Persona):
   def __init__(self, empleo, **kwargs):
      self.empleo = empleo
      super().__init__(**kwargs)

   def saluda(self):
      super().saluda()
      print(f"Trabajo como {self.empleo}")

class Estudiante_Empleado(Estudiante, Empleado):
   def __init__(self, nombre, apellido, especialidad, empleo):
      super().__init__(
            nombre=nombre,
            apellido=apellido,
            especialidad=especialidad,
            empleo=empleo
      )

   def saluda(self):
      super().saluda()

Este tipo de herencia en «diamante» suele ser complicado de utilizar, ya que puede existir ambigüedad en el órden de ejecución y los parámetros que se envían a los constructores por ejemplo. Anteriormente al utilizar super().__init__() ya sabíamos que parámetros enviar al nivel más arriba. Pero en este caso el constructor lo enviamos con los parámetros de ambos padres. ¿Como saben los constructores que parámetro tomar?. Para esto se hace uso del envío de parametros por keywords. Por ejemplo. Para el constructor de Empleado:

class Empleado(Persona):
    def __init__(self, empleo, **kwargs):
       self.empleo = empleo
       super().__init__(**kwargs)

El método dice: dame el argumento de empleo y los otros mantenlos en el diccionario. Incializa el atributo correspondiente y después le pide a super() que busque entre los «hermanos» de este nivel o siga buscando más arriba algun constructor. En este caso la clase a la que le pasa el resto de los argumentos es la clase Estudiante, esta clase toma el argumento con la especialidad y pasa el resto nombre y apellido a la clase Persona.

Nota

El método super() no siempre se refiere a la clase padre de la clase. En el caso de herencia múltiple búsca también en las clases hermanas. Esta sintáxis de super() sin parámetros es para versiones recientes del lenguaje.

El mismo flujo sucede cuando ejecutamos el método de imprimir un saludo. Si ejecutamos el programa el resultado debería de ser:

$ python herencia.py
Hola, soy Ana Lee
Trabajo como Asistente
Estudio Arquitectura

Llamamos MRO (Method Resolution Order) a la secuencia en la que Python busca a los métodos y atributos en una jerarquía de clases. Podemos ver la secuencia MRO de cualquier clase utilizando ya sea el atributo __mro__ o el método .mro(). Por ejemplo:

Estudiante_Empleado.mro()

El módulo dataclasses#

En algunas situaciones queremos trabajar con estructuras de datos que encapsulen atributos de manera compacta y clara, de forma similar a como utilizaríamos una tupla, pero con nombres asociados a cada campo. Para estos casos, Python incluye el módulo dataclasses, el cual permite definir clases de manera concisa utilizando el decorador @dataclass.

Este módulo agrega automáticamente métodos especiales como __init__ y __repr__, entre otros, reduciendo considerablemente la cantidad de código necesario. Esta funcionalidad se describe en la PEP 557.

Al definir los atributos de una dataclass se utilizan anotaciones de tipo. Veamos un ejemplo sencillo:

>>> from dataclasses import dataclass
>>> @dataclass
... class Empleado:
...     id: int
...     nombre: str
...     salario: float
...     horas: int = 8
...
...     def salario_diario(self) -> float:
...         return self.salario * self.horas
...
>>> ana = Empleado(1, 'Ana Lee', 34.23)
>>> ana.salario_diario()
273.84

En este caso, Python genera automáticamente el constructor utilizando los atributos definidos en la clase. Conceptualmente, el método generado es equivalente a lo siguiente:

def __init__(self, id: int, nombre: str, salario: float, horas: int = 8):
    self.id = id
    self.nombre = nombre
    self.salario = salario
    self.horas = horas

También se agrega automáticamente el método __repr__, lo que facilita la inspección del objeto:

>>> ana
Empleado(id=1, nombre='Ana Lee', salario=34.23, horas=8)

El módulo dataclasses incluye funciones auxiliares útiles, por ejemplo para representar los objetos como tuplas:

>>> from dataclasses import astuple
>>> astuple(ana)
(1, 'Ana Lee', 34.23, 8)

o como diccionarios:

>>> from dataclasses import asdict
>>> asdict(ana)
{'id': 1, 'nombre': 'Ana Lee', 'salario': 34.23, 'horas': 8}

Definición avanzada de campos#

Cada atributo puede definirse de manera más detallada utilizando la función field(). Por ejemplo, el parámetro default_factory recibe un callable sin argumentos que se ejecuta para generar el valor por defecto del campo:

from dataclasses import dataclass, field

@dataclass
class MisEnteros:
    mi_lista: list[int] = field(default_factory=list)

En este caso, se llama internamente al constructor list() cada vez que se crea una nueva instancia, evitando problemas comunes con valores mutables compartidos.

Otros parámetros de field() permiten una mayor flexibilidad. Por ejemplo:

from dataclasses import dataclass, field

@dataclass
class Alumno:
    id: int = field(repr=False)
    nombre: str = field(kw_only=True)
    correo: str = field(init=False)

    def __post_init__(self):
        self.correo = f'{self.nombre.lower()}@tijuana.tecnm.mx'

En este ejemplo:

  • El campo id no se muestra al imprimir el objeto, utilizando repr=False.

  • El campo nombre debe proporcionarse explícitamente como argumento con nombre, debido a kw_only=True.

  • El campo correo no se incluye en el constructor generado automáticamente, ya que se define con init=False.

El método especial __post_init__ se ejecuta inmediatamente después de que el objeto ha sido creado. Esto permite inicializar atributos que dependen de otros campos ya existentes, como en este caso el correo electrónico, que se construye a partir del nombre del alumno.

Resumen del capítulo#

En este capítulo revisamos el modelo de programación orientada a objetos en Python desde una perspectiva práctica: un lenguaje donde el paradigma OO es muy utilizado, pero no es obligatorio para escribir programas funcionales. Vimos la flexibilidad del lenguaje mediante introspección y la posibilidad de agregar atributos o métodos dinámicamente, destacando que esta capacidad debe usarse con cuidado en proyectos mantenibles. La herencia simple y múltiple, así como la importancia de construir constructores cooperativos utilizando super() y argumentos por keywords para evitar ambigüedades. Por último, el módulo dataclasses es muy práctico para definir clases de manera compacta.