Programación Orientada a Objetos en Python

Ingeniería del Software para Inteligencia Artificial

Tabla de contenidos

Introducción

Paradigma Orientado a Objetos

La Programación Orientada a Objetos (POO) es un paradigma de programación basado en la representación del mundo real mediante clases y objetos.

Nota

Un paradigma de programación es un enfoque o estilo de programación que define la manera en que se estructuran y organizan los programas.

Se basa en un conjunto de principios, conceptos y técnicas que guían cómo se diseña y escribe el software.

Paradigma Orientado a Objetos

La Programación Orientada a Objetos (POO) es un paradigma de programación basado en la representación del mundo real mediante clases y objetos.

En POO los programas se organizan en torno a objetos en lugar de funciones. Esto permite reutilizar código más fácilmente y hacerlo más fácil de mantener.

Comparativa
Programación procedural POO
Basada en funciones Basada en objetos
Secuencial Modular
Datos y funciones separados Datos y funciones juntos
Reutilización de código difícil Reutilización de código fácil

Código procedural

inventario = {
    "El Quijote": 7,
    "Don Juan Tenorio": 5,
    "La Celestina": 0
}

añadir_libro(inventario, "El Lazarillo de Tormes")
actualizar_existencias(inventario, "El Quijote", 8)

print(inventario) # Se imprime el diccionario con el formato predeterminado

promedio_ejemplares(inventario)
mostrar_resumen(inventario)

Código orientado a objetos

inventario = Inventario({
    "El Quijote": 7,
    "Don Juan Tenorio": 5,
    "La Celestina": 0
})

inventario.añadir("El Lazarillo de Tormes")
inventario.actualizar("El Quijote", 0)

print(inventario) # La clase controla el formato de salida,
                  # incluyendo toda la información necesaria:
                  # stock, promedio, resumen, ...

Características de la POO

  • Abstracción: Modela entidades del mundo real destacando sus características esenciales.
  • Encapsulación: Oculta detalles internos y expone solo lo necesario.
  • Herencia: Permite que una clase derive propiedades y comportamientos de otra.
  • Polimorfismo: Objetos de diferentes clases pueden responder a la misma interfaz.

Beneficios de la POO

✔️ Mejora la modularidad y la organización del código.
✔️ Facilita la reutilización de código con clases y objetos.
✔️ Permite un mantenimiento más sencillo y escalable.

POO en Python

POO en Python

Python permite usar distintos paradigmas de programación, incluyendo POO.

Casi todo en Python es un objeto, con sus propiedades y métodos.

texto = "Hola mundo"
print(type(texto))   # El tipo str es una clase
print(texto.upper()) # Convierte la cadena a mayúsculas
<class 'str'>
HOLA MUNDO

Clases vs. Objetos

Una clase es un modelo o plantilla que define la estructura y el comportamiento de un conjunto de objetos. Contiene atributos (datos) y métodos (funciones) que determinan cómo se comportan los objetos.

Las clases son una abstracción que representan las propiedades comunes de los objetos que queremos modelar.

Clases vs. Objetos

Una clase es un modelo o plantilla que define la estructura y el comportamiento de un conjunto de objetos. Contiene atributos (datos) y métodos (funciones) que determinan cómo se comportan los objetos.

Un objeto es una instancia concreta de una clase. Cada objeto tiene sus propios valores para los atributos definidos en la clase y puede ejecutar los métodos de la clase.

# i es una instancia de la clase Inventario
i = Inventario()
# Se añade un libro al inventario i
i.añadir("El Lazarillo de Tormes")

Guía de estilo

# i es una instancia de la clase Inventario
i = Inventario()

Convenciones de nombres

  • Las clases se nombran empezando en mayúsculas, usando CamelCase (DataLoader)
  • Los objetos (variables) y funciones se nombran en minúsculas, usando snake_case (image_loader, load_data())

Más información: PEP8 Naming Conventions

Guía de estilo

print(i) # ¿Qué es la variable i?

Código con mala legibilidad

Todos los nombres deben ser descriptivos

Guía de estilo

print(inventario)

data_loader = DataLoader("/path/to/data")
data_loader.load_data()

Este código resulta mucho más fácil de entender.

Documentación de código

Las clases y métodos se pueden documentar usando docstrings:

class DataLoader:
    """
    Loads data from a given folder

    Attributes
    ----------
    source : str
        folder to load data from
    input_format : str
        format of the input files
    
    Methods
    -------
    load_data():
        Loads all data from source folder
    """

Declaración de una clase

class DataLoader:
    def __init__(self, source):
        """Constructor de la clase DataLoader"""
        self.source = source

    def load_data(self):
        """Método para cargar datos"""
        print(f"Cargando datos desde {self.source}...")
  • Las funciones que pertenecen a una clase se llaman métodos
  • El constructor siempre se llama __init__()
  • Los métodos reciben un parámetro self, que representa la instancia para la que se ejecuta el método

Atributos de instancia

class DataLoader:
    def __init__(self, source):
        """Constructor de la clase DataLoader"""
        self.source = source

image_loader = DataLoader("/db/images")
text_loader = DataLoader("/db/text")

Los atributos de instancia pueden tener un valor diferente para cada instancia creada.

Se declaran añadiéndolos como propiedades de self.

Nota

Los atributos se pueden crear dentro de cualquier método, pero es recomendable hacerlo en el constructor e inicializarlos con algún valor o None.

Constructor

class DataLoader:
    def __init__(self, source):
        """Constructor de la clase DataLoader"""
        self.source = source

image_loader = DataLoader("/db/images")
text_loader = DataLoader("/db/text")

El constructor se encarga de inicializar la instancia en el momento de su declaración.

Importante

En Python no existe la sobrecarga de métodos con distintos parámetros.

Por tanto, sólo puede haber un constructor.

Sobrecarga de métodos

Se puede conseguir algo parecido a la sobrecarga usando valores por defecto para los parámetros.

class DataLoader:
    def __init__(self, source, input_format = "text"):
        """Constructor de la clase DataLoader"""
        self.source = source
        self.input_format = input_format

image_loader = DataLoader("/db/images", "image")
text_loader = DataLoader("/db/text")

print(image_loader.input_format)
print(text_loader.input_format)
image
text

Sobrecarga de métodos

**kwargs permite usar un número de argumentos variable.

class DataLoader:
    def __init__(self, **kwargs):
        """Constructor de la clase DataLoader con kwargs"""
        self.source = kwargs.get("source", "default_source")
        self.input_format = kwargs.get("input_format", "text")

image_loader = DataLoader(source="/db/images", input_format="image")
text_loader = DataLoader(source="/db/text")

Advertencia

También se puede usar kwargs como un diccionario:

self.input_format = kwargs["input_format"]

Pero si no se ha proporcionado ese parámetro saltará un error.

Parámetros con nombre

Al llamar a un método se puede especificar a qué parámetros se quiere asignar valores.

De esta forma da igual en qué orden se pongan los parámetros.

# Estas dos líneas son equivalentes
data_loader.load_data(source="/db/images", input_format="image")
data_loader.load_data(input_format="image", source="/db/images")

Tip

Es de especial utilidad cuando un método tiene muchos parámetros opcionales y sólo queremos dar valor a unos pocos.

Atributos de clase (estáticos)

Los atributos de clase sólo existen para la clase y son parecidos a una variable global.

No cambian de valor para cada instancia

class DataLoader:
    """Atributo de clase compartido por todas las instancias"""
    file_extension = ".csv"

    def __init__(self, source, input_format="text"):
        """Constructor de la clase DataLoader"""
        # ...

print(DataLoader.file_extension)
.csv

Usos de los atributos de clase

Para definir constantes:

class ChatGPT:
    BASE_URL = "https://api.openai.com/v1"

    @classmethod
    def get_models(cls):
        url = f"{cls.BASE_URL}/models"

print(ChatGPT.BASE_URL) # Se puede acceder porque es pública

Nota

En Python no existen las constantes, pero por convención se usan variables con nombres en MAYÚSCULAS.

Usos de los atributos de clase

Como contador de instancias:

class DataItem:
    __items = 0

    def __init__(self, path):
        self.path = path
        self.id = DataItem.__items
        DataItem.__items += 1

item1 = DataItem("/path/to/item/1")
item2 = DataItem("/path/to/item/2")
print(item1.id)
print(item2.id)
0
1

Métodos de clase (estáticos)

Los métodos de clase no operan sobre ninguna instancia.

Se especifican con @classmethod y reciben un parámetro cls que representa la propia clase.

class DataLoader:
    """Atributo de clase compartido por todas las instancias"""
    file_extension = ".csv"

    @classmethod
    def set_file_extension(cls, file_extension):
        cls.file_extension = file_extension

DataLoader.set_file_extension(".txt")
print(DataLoader.file_extension)
.txt

Encapsulación de información

La encapsulación de información es un principio fundamental de la Programación Orientada a Objetos (POO) que consiste en ocultar los detalles internos de una clase y restringir el acceso directo a sus atributos y métodos, permitiendo la interacción sólo a través de interfaces definidas (métodos públicos).

data_loader = DataLoader()

# ❌ No podemos validar si el formato es correcto
data_loader.input_format = "image" 

# ✅ El método puede comprobar si es un formato soportado
data_loader.set_input_format("image")

Encapsulación de información

Podemos garantizar la encapsulación de varias formas:

  • Controlando la visibilidad de atributos y métodos
  • Declarando propiedades

La visibilidad en POO determina cómo se pueden acceder y modificar los atributos y métodos de una clase.

Tipos de visibilidad en Python:

  • Pública
  • Protegida
  • Privada

Visibilidad pública

Accesible desde cualquier parte del código

class DataItem:
    def __init__(self):
        self.id = 0
    
    def get_id(self):
        return self.id

item = DataItem()
item.id = 20
item.get_id()
20

Visibilidad protegida

Se indica con un guión bajo (ej: _atributo).

Se puede acceder desde fuera, pero no se recomienda.

class DataItem:
    def __init__(self, path):
        self._path = path
        self._check_path()
    
    def _check_path(self):
        """Comprueba si path existe en el disco"""

item = DataItem("/path/to/item")
item._path = "" # ❌ No da error en la asignación, pero es incorrecto
item._check_path() # ❌ Esta llamada no debería hacerse

Visibilidad privada

Se indica con dos guiones bajos (ej: __atributo).

No se puede acceder desde fuera.

class DataItem:
    def __init__(self, path):
        self.__path = path
        self.__check_path()
    
    def __check_path(self):
        """Comprueba si path existe en el disco"""

item = DataItem("/path/to/item")
item.__path # ❌ Error, no es accesible
item.__check_path() # ❌ Error, no es accesible

Visibilidad protegida vs. privada

Los atributos y métodos protegidos son accesibles para las clases heredadas (subclases).

Los atributos privados sólo son accesibles a la propia clase.

😱 ¿Qué es la herencia y para qué sirve?

Lo veremos más adelante.

En cualquier caso, nunca intentes acceder desde fuera a elementos protegidos o privados.

Propiedades

Las propiedades permiten acceder a atributos protegidos o privados manteniendo la encapsulación.

class DataItem:
    def __init__(self, path):
        self.__path = path
        self.__check_path()
    
    @property
    def path(self):
        return self.__path

item = DataItem("/path/to/item")
print(item.path) # ✅ Accede al atributo usando la propiedad pública
item.path = "/new/path" # ❌ La propiedad es de sólo lectura

Propiedades

Se pueden modificar usando un método setter.

class DataItem:
    def __init__(self, path):
        self.__path = path
        self.__check_path()
    
    @property
    def path(self):
        return self.__path
    
    @path.setter
    def path(self, new_path):
        self.__path = new_path
        self.__check_path()

item = DataItem("/path/to/item")
print(item.path) # ✅ Accede al atributo usando la propiedad pública
item.path = "/new/path" # ✅ El método setter valida la entrada

Atributos y métodos mágicos

Atributos mágicos

Atributos predefinidos que Python asigna automáticamente a las clases.

  • __name__: Nombre del módulo o clase
  • __doc__: Documentación de la clase o función
  • __dict__: Diccionario con los atributos
  • __module__: Nombre del módulo donde se define la clase
  • __class__: Clase a la que pertenece el objeto

Métodos mágicos

Los métodos mágicos (también llamados dunder methods) permiten definir el comportamiento de nuestras clases en algunas situaciones específicas.

  • __init__: Constructor de la clase
  • __str__: Devuelve una representación legible del objeto
  • __repr__: Devuelve una representación para depurar
  • __len__: Devuelve el tamaño del objeto
  • __getitem__: Acceso con objeto[i]
  • __setitem__: Modificación con objeto[i] = valor

class DataSet:
    def __init__(self):
        self.__data = [1, 2, 3, 4, 5]
    
    def __len__(self):
        return len(self.__data)
    
    def __getitem__(self, index):
        if index >= 0 and index < len(self.__data):
            return self.__data[index]
        else:
            raise IndexError("Index out of range")
    
    def __setitem__(self, index, value):
        if index >= 0 and index < len(self.__data):
            self.__data[index] = value
        else:
            raise IndexError("Index out of range")

dataset = DataSet()
print(f"Tamaño del dataset: {len(dataset)}")
print(f"Elemento en la posición 2: {dataset[2]}")
dataset[2] = 10
print(f"Elemento en la posición 2 modificado: {dataset[2]}")
Tamaño del dataset: 5
Elemento en la posición 2: 3
Elemento en la posición 2 modificado: 10

class Dataset:
    def __init__(self):
        self.__data = [1, 2, 3, 4, 5]

    def __str__(self):
        values = ', '.join(map(str, self.__data))
        return f"Los valores del Dataset son: {values}"
    
    def __repr__(self):
        return f"[{self.__class__}] __data: {self.__data}"

dataset = Dataset()
print(dataset)
repr(dataset)
Los valores del Dataset son: 1, 2, 3, 4, 5
"[<class '__main__.Dataset'>] __data: [1, 2, 3, 4, 5]"

Objetos iterables (como las colecciones):

class Dataset:
    def __init__(self):
        self.__data = [1, 2, 3, 4, 5]
    
    def __iter__(self):
        self.__index = 0
        return self
    
    def __next__(self):
        if self.__index < len(self.__data):
            value = self.__data[self.__index]
            self.__index += 1
            return value
        else:
            raise StopIteration

dataset = Dataset()
for item in dataset:
    print(item, end=" ")
1 2 3 4 5 

Resumen

En esta clase hemos aprendido…

  • Cómo escribir código legible y bien documentado.
  • La sintaxis para crear clases en Python.
  • Algunos principios fundamentales de POO, como la abstracción y la encapsulación.
  • Cómo respetar la encapsulación usando la visibilidad.
  • El uso de métodos mágicos para enriquecer nuestras clases.