Saltar a contenido

Introducción a la Programación Orientada a Objetos

Paradigmas de programación

alt text

En programación, un paradigma es un enfoque concreto de desarrollar y estructurar el desarrollo de programas

Hasta ahora hemos aprendido a programar utilizando el paradigma imperativo o de programación estructurada

No es el único paradigma que existe. Hay otros como:

  • Funcional
  • Orientado a objetos

Paradigma de la programación imperativa o estructurada

Consiste en una secuencia de instrucciones que el ordenador debe ejecutar.

Los elementos más importantes en esta forma de programar son:

  1. Variables: zonas de memoria donde guardamos información.

  2. Tipos de datos: son los valores que se pueden almacenar.

  3. Expresiones: corresponde a operaciones entre variables (del mismo o distinto tipo)

  4. Estructuras de control:

    • Secuenciales: ejecución de instrucciones de forma consecutiva
    • Bucles: permiten ejecutar un conjunto de instrucciones varias veces
    • Condicionales: ejecutar una parte del código u otra en función de que se cumpla una condición o abortar la ejecución del programa.

Paradigma de la programación orientada a objetos

Se basa en la idea de agrupación de datos y funciones relacionados en "unidades" de información.

El tipo de datos nuevo que permite agrupar datos y funciones se llama clase

A cada variable de tipo clase se le denomina objeto

Ventajas de la POO

La POO no es mejor ni peor que otros paradigmas. Cada paradigma es más o menos útil en función del tipo de problema que queremos solucionar.

Algunas ventajas de la POO son:

  • Encapsulación de datos: los datos y las operaciones para modificarlos pertenecen al objeto y solo son accesibles desde el mismo.
  • Simplicidad: la creación de grandes sistemas es una tarea compleja, con muchos problemas que resolver. La capacidad de dividir la complejidad en problemas más pequeños, en objetos permite simplificar la tarea global.

  • Facilidad de modificación: cuando se basa en objetos y modela el sistema con ellos, es más fácil realizar el seguimiento de qué partes del sistema se deben modificar. Todo esto facilita la corrección de errores o agregar una característica nueva.

  • Capacidad de mantenimiento: en general, el mantenimiento del código es difícil y con el tiempo se complica más. Requiere disciplina en forma de una nomenclatura correcta y una arquitectura clara y coherente, entre otros aspectos. El uso de objetos facilita la búsqueda de un área concreta del código que necesita mantenimiento.
  • Reusabilidad: la definición de un objeto se puede usar muchas veces en muchas partes del sistema o, potencialmente, también en otros sistemas.

Clases

Una clase es una entidad que define una serie de elementos que determinan un estado (datos) y un comportamiento (operaciones sobre los datos que modifican su estado).

Hemos visto que python define una serie de tipos de datos primitivos incluidos con el lenguaje. Los hemos utilizado al definir variables (int, float, str, list, tuple, ....)

Cuando creamos una clase estamos definiendo un nuevo tipo de dato.

Para crear una clase en Python usamos la palabra reservada class

Una clase básica la podemos crear en Python de la siguiente forma:

class Coche:    # Declaración de la clase
    pass        # Código de la clase

Por convención se usa notación CamelCase a la hora de asignar nombres a las clases. Esta notación consiste en poner en mayúscula el primer carácter del nombre de la clase. Si el nombre tiene varias palabras se ponen juntas sin espacios y con el primer carácter de cada palabra en mayúscula (Ejemplo CocheCarrerasFormula1)

Creación de objetos

Una clase es un molde, una plantilla, un tipo de dato definido por el programador.

Un objeto es una variable del tipo de la clase que hemos definido.

En Python creamos un objeto asignando a una variable la clase que hemos definido:

coche = Coche()
Con la asignación anterior hemos creado un objeto de la clase Coche. A este proceso se le denomina instanciar o crear una instancia

Podemos crear todos los objetos de una clase que queramos:

c1 = Coche()
c2 = Coche()

Añadir atributos a una clase

Los atributos o datos encapsulados en un objeto se crean cuando se crea una instancia de un objeto.

Hay una función especial a la que se llama en el momento de la creación, denominada constructor.

Constructor

Un constructor es una función especial que solo se invoca al crear por primera vez el objeto. Por tanto, el constructor solo se llamará una vez.

En este método, se crean los atributos que debe tener el objeto. Además, se asignan valores iniciales a los atributos creados.

Por tanto, este método es el encargado de crear el estado inicial de un objeto,

En Python, el constructor tiene el nombre __init__().

A __init__() le podemos dar el número de parámetros que queramos, pero el primer parámetro de __init__() debe ser siempre una variable especial de nombre self que hace referencia al propio objeto y permite que le añadamos atributos al objeto:

1
2
3
4
class Coche:
    def __init__(self, color, velocidad):
        self.color = color
        self.velocidad = velocidad

La indentación de def es la que permite saber a Python que el método __init__() pertenece a la clase Coche.

En el cuerpo de __init__() hay dos instrucciones que usan la variable self: * self.color = color crea el atributo de nombre color y asigna al mismo el valor del parámetro color * self.velocidad = velocidad crea el atributo de nombre velocidad y asigna al mismo el valor del parámetro velocidad

Si ahora queremos crear un objeto de la clase Coche de la misma forma:

coche = Coche()
TypeError: __init__() missing 2 required positional arguments: 'color' and 'velocidad'

Python devuelve un error indicando que debemos pasar dos parámetros al crear el objeto.

coche_rojo = Coche("rojo", 20)
coche_blanco = Coche("blanco", 30)

Acabamos de crear dos instancias de la clase Coche: el objeto coche_rojo, un coche rojo que circula a 20 Kmh, y el objeto coche_blanco, que representa a un coche de color blanco que circula a 30 Kmh

Fíjate que aunque __init__() tiene 3 parámetros solo hemos pasado 2 al crear el objeto; Python se encarga de pasar de forma automática self en el momento de llamar al constructor y no debemos pasarlo como parámetro.

Después de crear un objeto podemos acceder a sus atributos usando notación punteada

>>> coche_rojo.color
'rojo'
>>> coche_blanco.velocidad
30

Atributos de clase

Los atributos que acabábamos de definir se llaman atributos de instancia y son específicos de cada objeto en el momento de crearlos.

Python permite definir también atributos de clase, son atributos que tienen el mismo valor en todos los objetos que creemos en una determinada clase.

Se crean asignando valor a una variable fuera del método __init__(). Dichas variables deben estar indentadas y ubicadas al principio de la definición de la clase. Además deben inicializarse.

Por ejemplo, todos los coches tienen en común tener 4 ruedas:

class Coche:
    # Atributos de clase
    ruedas = 4

    def __init__(self, color, velocidad):
        # Atributos de instancia
        self.color = color
        self.velocidad = velocidad

Con la notación punteada podemos acceder también a los atributos de clase

>>> coche_rapido = Coche("azul", 120)
>>> coche_rapido.ruedas
4
También podemos acceder poniendo directamente el nombre de la clase:

>>> Coche.ruedas
4

Los atributos de clase se suelen usar para representar información común a todas las instancias (constantes, configuración, contadores globales, etc.). Permiten cambiar un valor para todas las instancias modificándolo una sola vez en la clase.

1
2
3
4
5
6
7
8
9
# Ejemplo de atributo de clase como constante
class Circulo:
    PI = 3.14159

    def __init__(self, radio):
        self.radio = radio

    def area(self):
        return Circulo.PI * (self.radio ** 2)  # Se accede con el nombre de la clase, no con self
# Ejemplo de atributo de clase como contador de instancias. 
class Usuario:
    total_usuarios = 0

    def __init__(self, nombre):
        self.nombre = nombre
        Usuario.total_usuarios += 1

u1 = Usuario("Ana")
u2 = Usuario("Luis")
print(Usuario.total_usuarios)  # 2

Métodos de instancia

Los métodos de instancia son funciones definidas dentro de una clase y que solo pueden ser llamados desde un objeto de la clase.

El primer parámetro de un método, igual que en el constructor __init__() debe ser siempre self

class Coche:
    ruedas = 4

    def __init__(self, color, velocidad):
        self.color = color
        self.velocidad = velocidad

    # métodos de instancia
    def descripcion(self):
        return f"El coche tiene color {self.color} y una velocidad de {self.velocidad} Kmh"

    def acelera(self, incremento):
        self.velocidad += incremento
        return f"Nueva velocidad: {self.velocidad} Kmh"

Hemos añadido a la clase dos métodos de instancia:

  • descripcion() que devuelve una cadena mostrando el color y la velocidad del coche
  • acelera() que incrementa la velocidad del coche con el parámetro que le pasemos y devuelve una cadena de texto que informa de la nueva velocidad.

Para ejecutar los métodos de instancia usamos notación punteada.

>>> coche_rojo = Coche("rojo", 80)

>>> coche_rojo.descripcion()
'El coche tiene color rojo y una velocidad de 80 Kmh'

>>> coche_rojo.acelera(20)
'Nueva velocidad: 100 Kmh'

El método __str__()

Cuando creamos una clase es buena idea disponer de un método que permita mostrar la información relevante del objeto. En el caso anterior hemos creado un método de nombre descripcion() que realiza dicha función, pero esta no es la forma más "Pythonica" de hacerlo

Por ejemplo, si creamos una lista Python muestra una representación de la misma si usamos print() para mostrarla.

>>> names = ["Fletcher", "David", "Dan"]
>>> print(names)
['Fletcher', 'David', 'Dan']

Sin embargo, si llamamos con print() un objeto que hayamos creado de la clase Coche obtenemos algo como:

>>> print(coche_rojo)
<__main__.Coche object at 0x00aeff70>

Obtenemos un mensaje críptico que indica que coche_rojo es un objeto de la clase Coche y a continuación su dirección de memoria.

Podemos cambiar el resultado de print() sobre un objeto creando un método especial de nombre __str__().

Si para la clase anterior cambiamos el nombre del método descripcion() por __str__()ahora obtendremos un resultado más amigable al mostrar con print() el objeto:

class Coche:
    ruedas = 4

    def __init__(self, color, velocidad):
        self.color = color
        self.velocidad = velocidad

    # métodos de instancia
    def __str__(self):
        return f"El coche tiene color {self.color} y una velocidad de {self.velocidad} Kmh"

    def acelera(self, incremento):
        self.velocidad += incremento
        return f"Nueva velocidad: {self.velocidad} Kmh"

coche_rojo = Coche("rojo", 80)

print(coche_rojo)

Al ejecutar obtenemos:

El coche tiene color rojo y una velocidad de 80 Kmh

El método __repr__

El método especial __repr__: debe devolver una representación textual del objeto, pensada principalmente para desarrolladores. A diferencia de __str__ que busca ser legible y amigable.

Se recomienda al implementar __repr__

  • incluir el nombre de la clase
  • mostrar los valores relevantes
  • en lo posible, que sea válida como código Python
  • usar comillas adecuadas para strings
  • no debe ser demasiado larga, pero sí clara
class Coche:
    def __init__(self, color, velocidad):
        self.color = color
        self.velocidad = velocidad

    def __str__(self):
        return f"Coche {self.color} a {self.velocidad} km/h"

    def __repr__(self):
        return f"Coche(color={self.color!r}, velocidad={self.velocidad!r})"

coche = Coche("rojo", 100)
print(coche)        # usa __str__: Coche rojo a 100 km/h
print([coche])      # dentro de corchetes se usa __repr__: [Coche(color='rojo', velocidad=100)]
repr(coche)         # usando la función repr(): "Coche(color='rorjo', velocidad=200)"

Se recomienda: - Utilizar siempre __repr__ en clases que se vayan a depurar o usar en logs. - Utilizar __str__ si queremos una versión más amigable para usuarios finales.

Si la clase no define __str__, Python usa automáticamente __repr__.

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

    def __repr__(self):
        return f"Persona(nombre='{self.nombre}', edad={self.edad})"

p = Persona("Ana", 30)
print(repr(p))      # Persona(nombre='Ana', edad=30)

Destructores (__del__) y limitaciones en Python

En la programación orientada a objetos existe un concepto llamado destructor. Un destructor es un método especial que se ejecuta automáticamente cuando un objeto es eliminado de la memoria.

En Python, ese método se llama __del__.

El objetivo del destructor es realizar tareas de limpieza antes de que el objeto desaparezca: cerrar conexiones, liberar recursos o escribir mensajes de cierre.

Ejemplo de destructor

class Recurso:
    def __init__(self, nombre):
        self.nombre = nombre
        print(f"Recurso {self.nombre} creado.")

    def __del__(self):
        print(f"Recurso {self.nombre} destruido.")

r = Recurso("A")
del r  # Forzamos la eliminación del objeto

Salida esperada:

Recurso A creado.
Recurso A destruido.

Aquí, el método __del__ se ejecuta justo cuando el objeto deja de existir porque forzamos su ejecución con del.

Limitaciones del destructor en Python

Aunque el destructor existe, no siempre se comporta de forma predecible en Python. Esto se debe a cómo funciona el recolector de basura de este lenguaje:

  1. Momento incierto: no podemos saber con exactitud cuándo se destruirá un objeto. Puede ocurrir inmediatamente después de que deje de usarse, o más tarde.
  2. No garantizado al salir del programa: si el programa termina, algunos destructores pueden no ejecutarse.
  3. Referencias circulares: si dos objetos se referencian entre sí, el recolector puede no llamar a __del__.
  4. No recomendado para liberar recursos críticos: en Python es más seguro usar context managers (with) para asegurar que se cierren ficheros o conexiones.

Ejemplo con with (más seguro que depender de __del__):

with open("datos.txt", "w") as f:
    f.write("Hola")
# Aquí el fichero se cierra automáticamente al salir del bloque

Ciclo de vida de un objeto

El ciclo de vida de un objeto es el conjunto de etapas que atraviesa un objeto desde que se crea hasta que deja de existir en memoria.

1. Creación

Se crea un objeto a partir de una clase.

  • Se reserva memoria para el nuevo objeto
  • Se llama al constructor init para inicializar atributos
  • El objeto recibe su identidad única
p = Persona("Ana")

2. Uso

El objeto vive mientras haya referencias que lo apunten (variables, estructuras, retornos). Puede:

  • almacenar datos (atributos)
  • ejecutar métodos
  • cambiar su estado interno
  • interactuar con otros objetos
p.hablar()
p.nombre = "Ana María"

3. Alcance

Las variables locales mueren al salir de la función; variables globales viven hasta que el programa termina o se elimina la referencia.

Ejemplos de alcance local vs global:

class Coche:
    def __init__(self, color):
        self.color = color

def crear_local():
    coche_local = Coche("rojo")  # vive solo dentro de la función
    return "hecho"               # al salir, si no se guarda, pierde la referencia

coche_global = Coche("azul")     # vive mientras el programa siga o hasta hacer del coche_global

crear_local()
print(coche_global.color)        # sigue existiendo: "azul". No existe: "rojo"

Si crear_local() devolviera el objeto y lo guardáramos en otra variable, seguiría vivo porque conservaría una referencia. El recolector de basura de Python se encarga de liberar la memoria cuando ya no hay referencias al objeto.

4. Destrucción (finalización)

Un objeto deja de existir cuando ya no hay referencias que apunten a él. En Python, el recolector de basura (garbage collector) elimina los objetos que ya no pueden ser accedidos.

Esto puede suceder cuando:

  • la variable que lo referenciaba cambia de valor
  • el objeto sale de un ámbito (por ejemplo, una función)
  • se elimina explícitamente una referencia con del

Pero, como vimos, aunque el destructor existe, no siempre se comporta de forma predecible en Python.