Fundamentos

Tutorial Fundamentos: Composición

Programación OOP: Descubre la diferencia entre composición ('tiene un') y herencia ('es un') para desarrollar sistemas modulables y eficientes.

Aprende Fundamentos GRATIS y certifícate

Concepto de composición: "tiene un" vs. "es un"

La composición en programación orientada a objetos es una técnica que permite construir clases complejas a partir de otras más simples, estableciendo relaciones "tiene un" entre ellas. En lugar de utilizar la herencia para crear clases derivadas (relación "es un"), la composición crea clases que contienen instancias de otras clases como atributos.

En una relación "tiene un", una clase incorpora objetos de otras clases en su interior, delegando en ellos ciertas responsabilidades. Esto promueve un diseño más modular y flexible, facilitando la reutilización y el mantenimiento del código.

Por ejemplo, consideremos una clase Coche que "tiene un" motor y ruedas:

class Motor:
    def __init__(self, potencia):
        self.potencia = potencia

    def arrancar(self):
        print(f"El motor de {self.potencia} CV ha arrancado.")

class Rueda:
    def __init__(self, tamano):
        self.tamano = tamano

    def inflar(self):
        print(f"La rueda de {self.tamano} pulgadas ha sido inflada.")

class Coche:
    def __init__(self, potencia_motor, tamano_rueda):
        self.motor = Motor(potencia_motor)
        self.ruedas = [Rueda(tamano_rueda) for _ in range(4)]

    def conducir(self):
        self.motor.arrancar()
        for rueda in self.ruedas:
            rueda.inflar()
        print("El coche está listo para conducir.")

En este ejemplo, la clase Coche contiene un objeto Motor y una lista de objetos Rueda. La relación de composición indica que un coche "tiene un" motor y "tiene" cuatro ruedas. Esta estructura permite que Coche delegue funcionalidad específica a sus componentes, promoviendo la cohesión y reduciendo el acoplamiento entre clases.

Por otro lado, la herencia establece una relación "es un", en la que una clase derivada hereda atributos y métodos de una clase base. Por ejemplo:

class Vehiculo:
    def __init__(self, velocidad_maxima):
        self.velocidad_maxima = velocidad_maxima

    def mover(self):
        print(f"El vehículo se mueve a {self.velocidad_maxima} km/h.")

class Coche(Vehiculo):
    def __init__(self, velocidad_maxima, potencia_motor, tamano_rueda):
        super().__init__(velocidad_maxima)
        self.motor = Motor(potencia_motor)
        self.ruedas = [Rueda(tamano_rueda) for _ in range(4)]

    def conducir(self):
        self.motor.arrancar()
        print(f"Conduciendo a una velocidad máxima de {self.velocidad_maxima} km/h.")

Aquí, Coche es un tipo de Vehiculo, heredando sus atributos y métodos. Además, utiliza composición al incluir objetos Motor y Rueda. La combinación de herencia y composición permite crear estructuras más ricas y funcionales.

La elección entre composición ("tiene un") y herencia ("es un") depende de la relación lógica entre las clases:

Composición ("tiene un"): Utilizar cuando una clase necesita utilizar la funcionalidad de otra clase sin ser una especialización de ella. Promueve la reutilización y el encapsulamiento. Por ejemplo, un Libro tiene un Autor.

Herencia ("es un"): Aplicar cuando existe una relación de especialización. La clase derivada es un tipo específico de la clase base. Por ejemplo, un Perro es un Animal.

Es importante considerar que la composición ofrece mayor flexibilidad que la herencia. Al cambiar la implementación de una clase componente, las clases que la usan mediante composición pueden adaptarse más fácilmente sin alterar su estructura básica. Esto se alinea con el principio de composición sobre herencia, que sugiere favorecer la composición para crear sistemas más mantenibles.

Otro ejemplo práctico es modelar una Computadora que "tiene un" Procesador, "tiene un" DiscoDuro y "tiene un" MemoriaRAM:

class Procesador:
    def __init__(self, marca, nucleos):
        self.marca = marca
        self.nucleos = nucleos

class DiscoDuro:
    def __init__(self, capacidad):
        self.capacidad = capacidad

class MemoriaRAM:
    def __init__(self, tamano):
        self.tamano = tamano

class Computadora:
    def __init__(self, procesador, disco_duro, memoria_ram):
        self.procesador = procesador
        self.disco_duro = disco_duro
        self.memoria_ram = memoria_ram

    def mostrar_especificaciones(self):
        print(f"Procesador: {self.procesador.marca} con {self.procesador.nucleos} núcleos")
        print(f"Disco Duro: {self.disco_duro.capacidad} GB")
        print(f"Memoria RAM: {self.memoria_ram.tamaño} GB")

En este caso, Computadora está compuesta por varias partes, cada una representada por su propia clase. Esto facilita la extensibilidad, permitiendo actualizar o reemplazar componentes sin modificar la estructura general de Computadora.

En resumen, comprender la diferencia entre "tiene un" y "es un" es esencial para diseñar sistemas orientados a objetos robustos. La composición se utiliza para combinar comportamientos y funcionalidades de forma modular, mientras que la herencia se emplea para establecer jerarquías y relaciones de tipo. Elegir la estrategia adecuada según el contexto mejora la calidad y escalabilidad del código.

Diferencias entre herencia y composición

La herencia y la composición son técnicas clave en la programación orientada a objetos para reutilizar código y crear estructuras más complejas. Sin embargo, presentan diferencias significativas que afectan al diseño y mantenimiento de las aplicaciones.

La herencia se basa en una relación jerárquica donde una clase hija extiende a una clase padre, heredando sus atributos y métodos. Esto establece una relación "es un", donde la clase hija es una especialización de la clase padre. Por ejemplo, si tenemos una clase Animal, podríamos crear una clase Perro que hereda de Animal, ya que un perro "es un" animal.

class Animal:
    def comer(self):
        print("El animal está comiendo.")

class Perro(Animal):
    def ladrar(self):
        print("El perro está ladrando.")

En este caso, Perro hereda el método comer de Animal y añade su propio método ladrar. La jerarquía establecida facilita la reutilización de código, pero puede generar acoplamiento fuerte y dificultar cambios futuros en la estructura de clases.

Por otro lado, la composición implica que una clase contiene instancias de otras clases, estableciendo una relación "tiene un". Esto permite construir clases complejas a partir de componentes más simples, promoviendo un diseño más modular y flexible. Por ejemplo, una clase Coche puede tener un objeto Motor y una lista de objetos Rueda sin necesidad de heredar de ellos.

class Motor:
    def encender(self):
        print("El motor está encendido.")

class Rueda:
    def rodar(self):
        print("La rueda está rodando.")

class Coche:
    def __init__(self):
        self.motor = Motor()
        self.ruedas = [Rueda() for _ in range(4)]

    def conducir(self):
        self.motor.encender()
        for rueda in self.ruedas:
            rueda.rodar()
        print("El coche está en movimiento.")

Con la composición, el acoplamiento débil entre clases facilita la modificación de componentes individuales sin afectar al resto del sistema. Esto es especialmente útil en proyectos grandes donde la extensibilidad y el mantenimiento son cruciales.

Una diferencia esencial entre ambas técnicas es cómo manejan la polimorfismo. La herencia permite el polimorfismo a través de la sobrescritura de métodos, donde una clase hija puede ofrecer una implementación específica de un método de la clase padre.

class Animal:
    def hacer_sonido(self):
        print("El animal hace un sonido.")

class Perro(Animal):
    def hacer_sonido(self):
        print("El perro ladra.")

class Gato(Animal):
    def hacer_sonido(self):
        print("El gato maúlla.")

def sonido_animal(animal):
    animal.hacer_sonido()

sonido_animal(Perro())
sonido_animal(Gato())

En cambio, la composición utiliza la delegación para lograr comportamiento similar, donde una clase delega responsabilidades en sus componentes.

Otra diferencia radica en la reutilización de código. La herencia puede llevar a una reutilización excesiva, donde las clases heredan más de lo necesario, lo que puede introducir fragilidad en el diseño. La composición permite seleccionar específicamente qué componentes incluir, ofreciendo mayor control sobre las funcionalidades.

Es importante considerar el principio de composición sobre herencia, que sugiere favorecer la composición para evitar las limitaciones de la herencia múltiple y minimizar el acoplamiento. Este principio es ampliamente aceptado en el diseño de software moderno, ya que promueve estructuras más robustas y mantenibles.

Por ejemplo, si queremos modelar diferentes tipos de notificaciones en una aplicación, podríamos usar composición para añadir comportamientos específicos sin heredar de una clase base común.

class NotificacionEmail:
    def enviar(self, mensaje):
        print(f"Enviando correo electrónico: {mensaje}")

class NotificacionSMS:
    def enviar(self, mensaje):
        print(f"Enviando SMS: {mensaje}")

class Usuario:
    def __init__(self, notificacion):
        self.notificacion = notificacion

    def notificar(self, mensaje):
        self.notificacion.enviar(mensaje)

usuario_email = Usuario(NotificacionEmail())
usuario_sms = Usuario(NotificacionSMS())

usuario_email.notificar("Bienvenido al sistema.")
usuario_sms.notificar("Tu código de verificación es 1234.")

En este caso, Usuario utiliza composición para definir cómo notificar al usuario, permitiendo cambiar el método de notificación sin modificar la clase Usuario. Esto ilustra la flexibilidad que ofrece la composición en comparación con la herencia.

Por el contrario, si hubiéramos utilizado herencia, tendríamos que crear múltiples subclases de Usuario, lo que aumentaría la complejidad y reduciría la reusabilidad.

Además, la herencia puede complicar la migración y actualización de código. Si una clase base cambia, todas las clases derivadas pueden verse afectadas. La composición reduce este riesgo al aislar cambios en componentes específicos.

En conclusión, mientras la herencia y la composición son herramientas valiosas, entender sus diferencias es crucial para diseñar sistemas efectivos. La herencia es adecuada cuando existe una clara relación "es un", y se busca aprovechar el polimorfismo y la reutilización de código. La composición es preferible cuando se necesita flexibilidad y un diseño modular, favoreciendo componentes intercambiables y acoplamiento reducido.

Para elegir entre herencia y composición, es importante analizar:

  • Jerarquía lógica: Si la relación es natural y estable ("un gato es un animal"), la herencia es apropiada.
  • Reutilización específica: Si solo se requieren ciertas funcionalidades, la composición permite incluir solo lo necesario.
  • Cambios futuros: La composición facilita la adaptación a nuevos requisitos sin afectar a la estructura existente.

Adoptar un enfoque equilibrado que combine ambas técnicas según las necesidades del proyecto contribuirá a desarrollar código más limpio, eficiente y sostenible a largo plazo.

Diseño de clases usando composición para mayor modularidad

La composición es una estrategia efectiva en el diseño de clases para lograr sistemas más modulares y fáciles de mantener. Al construir clases a partir de componentes más pequeños y especializados, fomentamos la reutilización y la flexibilidad en nuestro código.

Diseñar clases utilizando composición implica asignar a cada clase una única responsabilidad, siguiendo el principio de responsabilidad única. Esto mejora la cohesión dentro de nuestras clases y reduce el acoplamiento entre ellas, facilitando así la colaboración y la evolución del sistema.

Mediante la descomposición de problemas complejos en partes más simples, identificamos funcionalidades que pueden ser delegadas a otras clases. Por ejemplo, en una aplicación de gestión de bibliotecas, podríamos tener una clase Biblioteca que contiene objetos de las clases Libro, Usuario y Préstamo. Así, Biblioteca coordina la interacción entre estos componentes sin depender directamente de sus implementaciones.

class Libro:
    def __init__(self, titulo, autor):
        self.titulo = titulo
        self.autor = autor

class Usuario:
    def __init__(self, nombre, id_usuario):
        self.nombre = nombre
        self.id_usuario = id_usuario

class Prestamo:
    def __init__(self, libro, usuario, fecha_prestamo):
        self.libro = libro
        self.usuario = usuario
        self.fecha_prestamo = fecha_prestamo

class Biblioteca:
    def __init__(self):
        self.libros = []
        self.usuarios = []
        self.prestamos = []

    def agregar_libro(self, libro):
        self.libros.append(libro)

    def registrar_usuario(self, usuario):
        self.usuarios.append(usuario)

    def realizar_prestamo(self, libro, usuario, fecha):
        prestamo = Prestamo(libro, usuario, fecha)
        self.prestamos.append(prestamo)

En este ejemplo, la clase Biblioteca utiliza composición para integrar Libro, Usuario y Prestamo. Cada clase es independiente y cumple una función específica, lo que simplifica su modificación y reutilización en otros contextos.

La composición facilita la sustitución de componentes. Si necesitamos cambiar cómo se maneja la clase Prestamo, podemos crear una nueva implementación y actualizar la referencia en Biblioteca sin alterar su estructura. Esto es especialmente útil al trabajar con múltiples versiones o variantes de un componente.

Al adoptar composición, promovemos el uso de interfaces claras entre las clases. Esto significa que nos centramos en cómo interactúan los componentes a través de sus métodos públicos, lo que mejora el mantenimiento y reduce la complejidad al modificar o extender funcionalidades.

La composición también permite adherirse al principio de inversión de dependencias, parte de los principios SOLID, al depender de abstracciones en lugar de implementaciones concretas. Esto aumenta la flexibilidad del sistema y facilita la incorporación de nuevas funcionalidades sin afectar al código existente.

Es esencial que las clases utilizadas en composición sean altamente cohesivas, es decir, que sus métodos y atributos estén directamente relacionados con su propósito. Esto mejora la legibilidad del código y hace que sea más intuitivo para otros desarrolladores entender y colaborar en el proyecto.

La modularidad lograda a través de la composición simplifica la creación de pruebas unitarias. Al tener componentes bien definidos y aislados, es más sencillo verificar su correcto funcionamiento individualmente, lo que incrementa la calidad general del software.

La composición se apoya en la delegación, permitiendo que las clases deleguen tareas a sus componentes internos. Esto evita la duplicación de código y sigue el principio DRY (Don't Repeat Yourself), resultando en sistemas más eficientes y mantenibles.

Al diseñar clases con composición, es recomendable definir interfaces claras y evitar el acceso directo a los atributos internos de los componentes. Esto mantiene la encapsulación y previene la creación de dependencias frágiles que podrían causar problemas ante cambios futuros en el código.

Ejemplos prácticos: implementación de sistemas complejos con objetos anidados

Para comprender la implementación de sistemas complejos con objetos anidados, vamos a desarrollar una aplicación de gestión para una biblioteca digital. Este sistema incluirá clases como Biblioteca, Sección, Libro y Autor, donde cada clase contendrá instancias de otras clases, demostrando la composición en acción.

Comenzamos definiendo la clase Autor, que representará a los autores de los libros. Cada autor tendrá atributos como nombre y nacionalidad.

class Autor:
    def __init__(self, nombre, nacionalidad):
        self.nombre = nombre
        self.nacionalidad = nacionalidad

La clase Libro contendrá un objeto Autor y otros atributos como titulo, ano_publicacion y genero. Esto muestra cómo un libro tiene un autor.

class Libro:
    def __init__(self, titulo, autor, ano_publicacion, genero):
        self.titulo = titulo
        self.autor = autor  # Composición: Libro contiene un objeto Autor
        self.ano_publicacion = ano_publicacion
        self.genero = genero

A continuación, la clase Seccion representará una categoría dentro de la biblioteca, como "Ciencia Ficción" o "Historia", y contendrá una lista de objetos Libro.

class Seccion:
    def __init__(self, nombre):
        self.nombre = nombre
        self.libros = []  # Lista de objetos Libro
    
    def agregar_libro(self, libro):
        self.libros.append(libro)
    
    def mostrar_libros(self):
        print(f"Sección: {self.nombre}")
        for libro in self.libros:
            print(f"- {libro.titulo} de {libro.autor.nombre}")

Finalmente, la clase Biblioteca contendrá múltiples objetos Seccion, anidando así los objetos y creando una estructura más compleja.

class Biblioteca:
    def __init__(self, nombre):
        self.nombre = nombre
        self.secciones = []  # Lista de objetos Sección
    
    def agregar_seccion(self, seccion):
        self.secciones.append(seccion)
    
    def mostrar_biblioteca(self):
        print(f"Biblioteca: {self.nombre}")
        for seccion in self.secciones:
            seccion.mostrar_libros()

Ahora, podemos crear instancias de estas clases y ver cómo interactúan entre sí.

# Crear autores
autor1 = Autor("Isaac Asimov", "Rusia")
autor2 = Autor("J.K. Rowling", "Reino Unido")

# Crear libros
libro1 = Libro("Fundación", autor1, 1951, "Ciencia Ficción")
libro2 = Libro("Harry Potter y la piedra filosofal", autor2, 1997, "Fantasía")

# Crear secciones
sección_cf = Seccion("Ciencia Ficción")
sección_fantasía = Seccion("Fantasía")

# Agregar libros a las secciones
sección_cf.agregar_libro(libro1)
sección_fantasía.agregar_libro(libro2)

# Crear biblioteca y agregar secciones
biblioteca = Biblioteca("Biblioteca Central")
biblioteca.agregar_seccion(sección_cf)
biblioteca.agregar_seccion(sección_fantasía)

# Mostrar contenido de la biblioteca
biblioteca.mostrar_biblioteca()

La salida del programa sería:

Biblioteca: Biblioteca Central
Sección: Ciencia Ficción
- Fundación de Isaac Asimov
Sección: Fantasía
- Harry Potter y la piedra filosofal de J.K. Rowling

En este ejemplo, vemos cómo la composición permite estructurar un sistema complejo mediante el anidamiento de objetos. La Biblioteca contiene secciones, cada Sección contiene libros, y cada Libro contiene un autor. Este enfoque facilita la modularidad, ya que cada clase tiene una responsabilidad específica y puede ser modificada o extendida sin afectar significativamente al resto del sistema.

Otro ejemplo práctico es la implementación de un sistema de gestión de eventos, donde tenemos clases como Evento, Participante, Ubicación y Agenda.

Definimos la clase Participante:

class Participante:
    def __init__(self, nombre, correo):
        self.nombre = nombre
        self.correo = correo

La clase Ubicación representará el lugar donde se llevará a cabo el evento:

class Ubicacion:
    def __init__(self, nombre, direccion):
        self.nombre = nombre
        self.direccion = direccion

La clase Agenda contendrá la programación de actividades dentro del evento:

class Agenda:
    def __init__(self):
        self.actividades = []
    
    def agregar_actividad(self, hora, descripcion):
        self.actividades.append((hora, descripcion))
    
    def mostrar_agenda(self):
        for actividad in self.actividades:
            print(f"{actividad[0]} - {actividad[1]}")

La clase Evento integrará todos estos componentes:

class Evento:
    def __init__(self, nombre, ubicacion):
        self.nombre = nombre
        self.ubicacion = ubicacion   # Composición: Evento tiene una Ubicación
        self.participantes = []      # Lista de Participantes
        self.agenda = Agenda()       # Composición: Evento tiene una Agenda
    
    def agregar_participante(self, participante):
        self.participantes.append(participante)
    
    def mostrar_detalles(self):
        print(f"Evento: {self.nombre}")
        print(f"Ubicación: {self.ubicacion.nombre}, {self.ubicacion.direccion}")
        print("Participantes:")
        for participante in self.participantes:
            print(f"- {participante.nombre} ({participante.correo})")
        print("Agenda:")
        self.agenda.mostrar_agenda()

Utilizamos estas clases para crear un evento completo:

# Crear ubicación
ubicacion = Ubicacion("Centro de Convenciones", "Av. Principal 123")

# Crear evento
evento = Evento("Conferencia de Tecnología", ubicacion)

# Agregar participantes
participante1 = Participante("Laura López", "laura@example.com")
participante2 = Participante("Carlos Pérez", "carlos@example.com")
evento.agregar_participante(participante1)
evento.agregar_participante(participante2)

# Agregar actividades a la agenda
evento.agenda.agregar_actividad("09:00", "Registro de participantes")
evento.agenda.agregar_actividad("10:00", "Inauguración")
evento.agenda.agregar_actividad("11:00", "Charla: El Futuro de la IA")

# Mostrar detalles del evento
evento.mostrar_detalles()

La ejecución del código proporciona una visión detallada del evento, evidenciando cómo los objetos están anidados dentro de otros objetos, y cómo la composición facilita la organización de la información. Este tipo de implementaciones son comunes en el desarrollo de software empresarial, donde los sistemas manejan datos complejos y relaciones múltiples entre entidades. La flexibilidad que ofrece la composición permite adaptar y escalar el sistema según sea necesario.

Además, el uso de objetos anidados mejora la legibilidad del código, ya que las relaciones entre las clases reflejan de manera natural las relaciones del mundo real que están modelando.

Para seguir leyendo hazte Plus

¿Ya eres Plus? Accede a la app

Plan mensual

19.00 € /mes

Precio normal mensual: 19 €
47 % DE DESCUENTO

Plan anual

10.00 € /mes

Ahorras 108 € al año
Precio normal anual: 120 €
Aprende Fundamentos GRATIS online

Todas las lecciones de Fundamentos

Accede a todas las lecciones de Fundamentos y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a Fundamentos y certifícate

En esta lección

Objetivos de aprendizaje de esta lección

  • Entender los conceptos de composición y herencia.
  • Diferenciar entre una relación "tiene un" y "es un".
  • Implementar clases usando composición para mejorar modularidad.
  • Aplicar principios como cohesión y acoplamiento bajo.
  • Mejorar la mantenibilidad y extensibilidad del código.
  • Utilizar composición para delegar responsabilidades en subcomponentes.