Programación Orientada a Objetos

Clases y objetos

Atributos

  • Un atributo (o campo) guarda el estado de un objeto.
  • Puede ser:
    • De instancia: cada objeto tiene su propio valor.
    • Estático (static): compartido por todos los objetos de la clase.
  • Se declaran dentro de la clase, fuera de métodos.

Ejemplo:

public class Cuenta {
	private String titular;    // atributo de instancia
	private double saldo;      // atributo de instancia
	public static String banco = "Notion Bank"; // atributo estático
}

Buenas prácticas:

  • Casi siempre declarar atributos como private.
  • Evitar exponer atributos públicos (rompe el principio de encapsulamiento).
  • Mantener coherencia: si un atributo no debe cambiar, evaluar final.

Métodos

  • Un método define el comportamiento de la clase.
  • Puede:
    • Leer o modificar atributos.
    • Validar reglas.
    • Devolver resultados.
  • Tipos comunes:
    • De instancia: operan sobre el estado del objeto.
    • Estáticos (static): utilitarios o lógica que no depende del estado.

Ejemplo:

public class Cuenta {
	private double saldo;
 
	public void depositar(double monto) {
		if (monto <= 0) {
			throw new IllegalArgumentException("monto debe ser > 0");
		}
		saldo += monto;
	}
 
	public double getSaldo() {
		return saldo;
	}
}

Constructores

  • Un constructor inicializa un objeto cuando se invoca new.
  • Tiene el mismo nombre que la clase y no tiene tipo de retorno.
  • Puede haber:
    • Constructor por defecto: si no se define ninguno.
    • Constructores personalizados: para exigir datos iniciales.
    • Sobrecarga de constructores: varias firmas.

Ejemplo:

public class Cuenta {
	private final String titular;
	private double saldo;
 
	public Cuenta(String titular) {
		this.titular = titular;
		this.saldo = 0;
	}
 
	public Cuenta(String titular, double saldoInicial) {
		this.titular = titular;
		this.saldo = saldoInicial;
	}
}

Tip: usar this(...) para llamar a otro constructor y evitar duplicar lógica.

Modificadores de acceso

Controlan desde dónde se puede acceder a clases, atributos y métodos.

  • public: accesible desde cualquier lugar.
  • protected: accesible desde el mismo paquete y subclases.
  • (sin keyword) (package-private): accesible solo dentro del paquete.
  • private: accesible solo dentro de la clase.
Modificador¿Dónde se puede acceder?
publicEn cualquier parte
protectedMismo paquete y subclases
package-privateSolo mismo paquete
privateSolo misma clase

🔐 Encapsulamiento

El encapsulamiento es uno de los pilares fundamentales de la Programación Orientada a Objetos.

Su objetivo principal es:

Proteger el estado interno de un objeto y controlar cómo se modifica.

En lugar de permitir que cualquier parte del programa modifique los datos directamente, el objeto expone métodos controlados para interactuar con su estado, recuerden que los objetos se componen de estados y comportamientos, sus características fundamentales

Esto permite:

  • Evitar modificaciones incorrectas
  • Mantener consistencia en los datos
  • Facilitar mantenimiento del código
  • Reducir errores

private

La palabra reservada private es un modificador de acceso.

Significa que un atributo o método solo puede ser accedido desde la misma clase.

Ejemplo incorrecto (sin encapsulamiento)
public class CuentaBancaria {
 
    public double saldo;
 
}
 
CuentaBancaria cuenta = new CuentaBancaria();
cuenta.saldo += -5000;

Esto es un error, ya que nada evita que el saldo entre en un estado inválido.

Ejemplo correcto
public class CuentaBancaria{
		private double saldo;
}
 
CuentaBancaria cuenta = new CuentaBancaria();
cuenta.saldo += -5000;

Lo anterior lanzaría una excepción, manteniendo el estado interno del objeto protegido, y obligandonos a interactuar con este mediante metodos controlados.

Getters y Setters

Los getters y setters son métodos que permiten leer o modificar atributos privados de forma controlada.

  • Getter → permite leer el valor
  • Setter → permite modificar el valor
public class Persona {
 
    private String nombre;
    private int edad;
 
    public String getNombre() {
        return nombre;
    }
 
    public void setNombre(String nombre) {
        this.nombre = nombre;
    }
 
}
Persona persona = new Persona();
 
persona.setNombre("Alejandro");
 
System.out.println(persona.getNombre());

La salida es Alejandro

Inmutabilidad

Un objeto inmutable es un objeto que no puede cambiar su estado despues de ser creado

Esto significa que sus atributos:

  • Se incializan en el constructor (Ya que no cambian, no es posible inicializarlos luego)
  • No tiene setters
  • Son de tipo final

Ejemplo

public class Usuario {
 
    private final String nombre;
    private final String email;
 
    public Usuario(String nombre, String email) {
        this.nombre = nombre;
        this.email = email;
    }
 
    public String getNombre() {
        return nombre;
    }
 
    public String getEmail() {
        return email;
    }
 
}
Usuario usuario = new Usuario("Alejandro", "alejandro@email.com");
 
//Despues de creado no puede cambiar
usuario.setNombre("Pedro"); // no existe

Ventajas

  • Más facil de razonar
  • Evita errores
  • Thread-safe
  • Muy usados en programación funcional
  • Muy común en diseño de sistemas moderno

Principios de diseño básicos

El encapsulamiento ayuda a cumplir principios importantes de diseño de software

  1. Ocultamiento de información

Una clase solo expone lo estrictamente necesario

  1. Alta cohesión

Una clase debe tener una responsabilidad clara

  1. Bajo acoplamiento

Encapsular estado ayuda a evitar dependencias innecesarias.

  1. Control de estado interno

Un objeto protege sus reglas internas

🧬 Herencia

La herencia es uno de los pilares de la Programación Orientada a Objetos.

Permite que una clase herede atributos y comportamientos de otra clase.

Esto permite:

  • reutilizar código
  • evitar duplicación
  • modelar relaciones del mundo real
  • extender comportamientos existentes

En términos simples:

Una clase puede extender otra clase y reutilizar sus funcionalidades.


Ejemplo básico de herencia

Supongamos que tenemos una clase Animal.

public class Animal {
 
    public void comer() {
        System.out.println("El animal está comiendo");
    }
 
}

Ahora podemos crear una clase más especifica

public class Perro extends Animal {
 
    public void ladrar() {
        System.out.println("Guau!");
    }
 
}
Perro perro = new Perro();
 
perro.comer();
perro.ladrar();
 
//Salida:
// El animal está comiendo
/// Guau!

Extends

La palabra reservada extends se usa para indicar que una clase hereda de otra, por lo general se asocia al termino “ES UN” para entender este comportamiento

Por ejemplo, un perro “Es un” animal, es decir con extends heredamos dicho comportamiento a perro.

Un ejemplo incorrecto: Motor → Auto, un motor no es un auto, sino que está o es contenido en un auto, por lo que no debemos usar herencia.

Super

La reservada super permite acceder a miembros de la clase padre, se usa principalmente para:

  • Llamar constructores del padre
  • Acceder a métodos (comportamientos) del padre
  • Acceder a atributos (estado) del padre

Ejemplo:

public class Persona {
 
    protected String nombre;
 
    public Persona(String nombre) {
        this.nombre = nombre;
    }
 
}
 
public class Estudiante extends Persona {
 
    private String carrera;
 
    public Estudiante(String nombre, String carrera) {
        super(nombre);
        this.carrera = carrera;
    }
 
}

Super llama al constructor de la clase padre, esto se puede replicar también con los métodos del padre.

Override

El override permite que una clase hija reemplace el comportamiento o implementación de un método de la clase padre, @Override le indica al compilador que estamos sobreescribiendo un método existente

Ejemplo

public class Animal {
 
    public void sonido() {
        System.out.println("El animal hace un sonido");
    }
 
}
 
public class Perro extends Animal {
 
    @Override
    public void sonido() {
        System.out.println("El perro ladra");
    }
 
}
 
Perro perro = new Perro();
perro.sonido();
 
//SALIDA:
// El perro ladra

Problemas con la herencia

Aunque es uno de los pilares de POO, puede causar problemas de diseño si se usa incorrectamente

  1. Alto acoplamiento
  2. Jerarquias complejas
  3. Herencias incorrectas
  4. Problema del metodo roto (Si la clase padre cambia, puede afectar a los hijos)

Polimorfismo

Sobrescritura

Como vimos anteriormente, ocurre cuando una clase hija redefine un método de la clase padre para darle un comportamiento diferente, conocido como polimorfismo dinámico

Cuando se usa, se debe tener en cuenta que:

  • El método debe existir en la clase padre
  • Debe tener el mismo nombre
  • Debe tener los mismos parámetros
  • Se usa la anotación @Override para indicar la sobreescritura

Interfaces

Son contratos que definen comportamientos que las clases deben cumplir, una intefaz no implementa un comportamiento completo, solo define qué métodos se deben cumplir, se usa la palabra reservada interface

Ejemplo:

interface Vehiculo{
	
    void acelerar();
 
    void frenar();
}
// Una clase implementa el contrato de la interface 
//mediante la palabra reservada "implements"
class Carro implements Vehiculo {
 
    @Override
    public void acelerar() {
        System.out.println("El carro acelera");
    }
 
    @Override
    public void frenar() {
        System.out.println("El carro frena");
    }
 
}

Ventajas

  • Permiten desacoplar código
  • Definir contratos
  • Facilitar pruebas unitarias
  • Permitir múltiples comportamientos

Java por defecto no permite la herencia múltiple de clases, por lo que una manera de realizarlo es utilizar interfaces:

interface Volador {
    void volar();
}
 
interface Nadador {
    void nadar();
}
 
class Pato implements Volador, Nadador {
 
    @Override
    public void volar() {
        System.out.println("El pato vuela");
    }
 
    @Override
    public void nadar() {
        System.out.println("El pato nada");
    }
 
}
// Esto permite modelar comportamientos de forma flexible.

Sobrecarga

Permite definir múltiples métodos con el mismo nombre dentro de una misma clase, pero con diferentes parámetros.  Esto permite que un mismo nombre de método se utilice para realizar operaciones similares con distintos tipos o cantidades de datos, está asociado al polimorfismo estático, ya que este actúa en tiempo de compilación.

Ejemplo:

public class Calculadora {
    public int sumar(int a, int b) {
        return a + b;
    }
 
    public double sumar(double a, double b) {
        return a + b;
    }
 
    public int sumar(int a, int b, int c) {
        return a + b + c;
    }
}

Abstracción

La abstracción consiste en mostrar solo la información necesaria y ocultar la complejidad interna.

En otras palabras:

El usuario usa el objeto sin preocuparse por cómo funciona internamente.

Ejemplo del mundo real:

  • Un carro
  • Sabes usar:
    • volante
    • freno
    • acelerador
  • No necesitas saber cómo funciona el motor internamente

Ejemplo:

public void pagar() {
    procesarPago();
}

El usuario solo llama al método pagar, pero internamente desconoce como ocurren las operaciones.

Clases abstractas

Una clase abstracta es una clase que no puede ser instanciada directamente.

Se usa como base para otras clases.

Se define con la palabra reservada abstract

Ejemplo:

abstract class Animal {
 
    public abstract void hacerSonido();
 
}

Una clase abstracta puede tener:

  • Métodos abstractos (sin implementación)
  • Métodos normales (con implementación)
  • Atributos

Diferencia interface vs clase abstracta

CaracterísticaInterfaceClase Abstracta
Herencia múltipleNo
Métodos con implementaciónSí (default)
AtributosConstantesVariables normales
ConstructorNo
Uso principalContratosBase de jerarquía

Relaciones entre clases

Asociación

La asociación es la relación más básica entre dos clases.

Significa simplemente que una clase usa o conoce a otra clase.

No implica propiedad ni dependencia fuerte.

Ejemplo conceptual

Un Profesor puede enseñar a un Estudiante.

Ambos existen independientemente.

Ejemplo en código:

class Estudiante {
 
    private String nombre;
 
    public Estudiante(String nombre) {
        this.nombre = nombre;
    }
 
}
 
class Profesor {
 
    private String nombre;
    private Estudiante estudiante;
 
    public Profesor(String nombre, Estudiante estudiante) {
        this.nombre = nombre;
        this.estudiante = estudiante;
    }
 
}
 
public class Main {
 
    public static void main(String[] args) {
 
        Estudiante estudiante = new Estudiante("Carlos");
 
        Profesor profesor = new Profesor("Ana", estudiante);
 
    }
 
}
// Aquí existe una relación entre Profesor y Estudiante,
// pero ninguno depende completamente del otro.

Tipos:

  • Uno a uno
    • Persona → Documento Identidad
  • Uno a muchos
    • Profesor → Estudiantes
  • Muchos a muchos
    • Estudiantes → Cursos

Agregación

La agregación es un tipo de asociación donde una clase contiene a otra, pero la clase contenida puede existir por sí sola.

Es una relación débil de pertenencia.

Ejemplo conceptual

Un Equipo tiene Jugadores.

Pero los jugadores pueden existir sin el equipo.

static class Jugador {
 
    private String nombre;
 
    public Jugador(String nombre) {
        this.nombre = nombre;
    }
 
}
 
import java.util.List;
 
static class Equipo {
 
    private String nombre;
    private List<Jugador> jugadores;
 
    public Equipo(String nombre, List<Jugador> jugadores) {
        this.nombre = nombre;
        this.jugadores = jugadores;
    }
 
}
 
public class Main {
 
    public static void main(String[] args) {
 
        Jugador j1 = new Jugador("Messi");
        Jugador j2 = new Jugador("Suarez");
 
        Equipo equipo = new Equipo("Barcelona", List.of(j1, j2));
 
    }
 
}

Composición

La composición es una relación más fuerte que la agregación.

En composición:

  • Un objeto contiene otro objeto
  • El objeto contenido no puede existir sin el contenedor

Si el objeto principal se destruye, los objetos internos también desaparecen.

Ejemplo conceptual

Un Carro tiene un Motor.

El motor forma parte del carro.

class Motor {
 
    public void encender() {
        System.out.println("Motor encendido");
    }
 
}
 
class Carro {
 
    private Motor motor;
 
    public Carro() {
        this.motor = new Motor();
    }
 
    public void encender() {
        motor.encender();
    }
 
}
 
public class Main {
 
    public static void main(String[] args) {
 
        Carro carro = new Carro();
        carro.encender();
 
    }
 
}

Dependencia

La dependencia ocurre cuando una clase usa otra temporalmente, generalmente como:

  • parámetro
  • variable local
  • retorno de método

Es la relación más débil entre clases.

class Impresora {
 
    public void imprimir(String documento) {
        System.out.println("Imprimiendo: " + documento);
    }
 
}
 
class ReporteService {
 
    public void generarReporte(Impresora impresora) {
 
        String reporte = "Reporte financiero";
 
        impresora.imprimir(reporte);
 
    }
 
}
 
public class Main {
 
    public static void main(String[] args) {
 
        Impresora impresora = new Impresora();
        ReporteService service = new ReporteService();
 
        service.generarReporte(impresora);
 
    }
 
}

Composición vs herencia

Este es uno de los principios más importantes en diseño orientado a objetos.

Existe una regla muy conocida:

Favor composition over inheritance

Es decir:

Prefiere composición antes que herencia

Problemas de la herencia:

  • Jerarquías rígidas
  • Alto acoplamiento
  • Difícil mantenimiento
  • Cambios en la clase padre afectan a todas las hijas

Ventajas de la composición:

  • Más flexible
  • Menos acoplamiento
  • Fácil de extender
  • Permite cambiar comportamientos dinámicamente
// Herencia:
class Pajaro {
    public void volar() {}
}
 
class Pinguino extends Pajaro {
}
// Problema: Los pinguinos no vuelan
 
//Composición:
interface Volador {
    void volar();
}
class VueloNormal implements Volador {
 
    public void volar() {
        System.out.println("Volando");
    }
 
}
 
class SinVuelo implements Volador {
 
    public void volar() {
        System.out.println("No puede volar");
    }
 
}
class Pajaro {
 
    private Volador comportamientoVuelo;
 
    public Pajaro(Volador comportamientoVuelo) {
        this.comportamientoVuelo = comportamientoVuelo;
    }
 
    public void volar() {
        comportamientoVuelo.volar();
    }
 
}
 
Pajaro aguila = new Pajaro(new VueloNormal());
Pajaro pinguino = new Pajaro(new SinVuelo());

Mixins

Los mixins son una forma de reutilizar comportamiento entre clases sin usar herencia tradicional.

En lugar de crear una jerarquía rígida de clases, los mixins permiten agregar funcionalidades específicas a una clase.

En muchos lenguajes (como Ruby o Scala) los mixins son una característica del lenguaje.

En Java se pueden simular usando interfaces con métodos default.

Esto permite compartir comportamiento entre clases sin obligarlas a heredar de una misma clase padre.

Problema que resuelven los mixins

Supongamos que tenemos diferentes clases:

  • Usuario
  • Producto
  • Orden
  • Archivo

Todas necesitan registrar logs.

Una solución sería crear una clase base:

class Logger {
    public void log(String mensaje) {
        System.out.println(mensaje);
    }
}
// Pero java no permite herencia multiple, por lo que no podriamos usar:
 
class Usuario extends Entidad, Logger

Desde Java 8, las interfaces pueden tener métodos con implementación usando default.

Esto permite compartir comportamiento

interface LoggerMixin {
 
    default void log(String mensaje) {
        System.out.println("[LOG] " + mensaje);
    }
 
}
 
class Usuario implements LoggerMixin {
 
    private String nombre;
 
    public Usuario(String nombre) {
        this.nombre = nombre;
    }
 
    public void crearUsuario() {
        log("Usuario creado: " + nombre);
    }
 
}
 
public class Main {
 
    public static void main(String[] args) {
 
        Usuario usuario = new Usuario("Carlos");
        usuario.crearUsuario();
 
    }
 
}

Cuándo usar mixins

Los mixins son útiles cuando:

  • Se necesita compartir comportamiento entre clases no relacionadas
  • Querer evitar jerarquías de herencia complejas
  • Querer agregar funcionalidades modulares

Ejemplo real:

Una clase podría necesitar:

  • Logging
  • Auditoría
  • Validación
  • Métricas

En lugar de crear una jerarquía gigante, se combinan mixins.

CaracterísticaHerenciaMixins
JerarquíaRígidaFlexible
ReutilizaciónLimitadaAlta
AcoplamientoAltoBajo
Composición de comportamientoDifícilFácil

SOLID

Los principios SOLID son cinco reglas de diseño orientado a objetos que ayudan a crear software:

  • Más mantenible
  • Más escalable
  • Más flexible
  • Más fácil de probar

Fueron popularizados por Robert C. Martin (Uncle Bob)

LetraPrincipio
SSingle Responsibility Principle
OOpen/Closed Principle
LLiskov Substitution Principle
IInterface Segregation Principle
DDependency Inversion Principle

Single Responsibility Principle (SRP)

Definición

Una clase debe tener una sola razón para cambiar.

Esto significa que una clase debe tener una única responsabilidad.

class UsuarioService {
 
    public void guardarUsuario(String nombre) {
        // guardar en base de datos
    }
 
    public void enviarEmailBienvenida(String nombre) {
        // enviar email
    }
 
    public void generarReporteUsuarios() {
        // generar reporte
    }
 
}

Problemas:

Esta clase tiene tres responsabilidades:

  • Persistencia
  • Envío de correos
  • Generación de reportes

Solución

Separar las responsabilidades

class UsuarioRepository {
 
    public void guardar(String nombre) {
        System.out.println("Guardando usuario");
    }
 
}
 
class EmailService {
 
    public void enviarBienvenida(String nombre) {
        System.out.println("Enviando email");
    }
 
}
 
class ReporteService {
 
    public void generarReporteUsuarios() {
        System.out.println("Generando reporte");
    }
 
}

Cada clase ahora tiene una única responsabilidad.

Open/Closed Principle (OCP)

Definición

El software debe estar abierto para extensión pero cerrado para modificación.

Esto significa que debemos poder agregar nuevas funcionalidades sin modificar el código existente.

class CalculadoraDescuentos {
 
    public double calcular(String tipoCliente, double precio) {
 
        if (tipoCliente.equals("REGULAR")) {
            return precio * 0.9;
        }
 
        if (tipoCliente.equals("VIP")) {
            return precio * 0.8;
        }
 
        return precio;
    }
 
}
 
// Problema: Cada nuevo tipo de cliente requiere modificar la clase

Solución

interface Descuento {
 
    double aplicar(double precio);
 
}
 
class DescuentoRegular implements Descuento {
 
    public double aplicar(double precio) {
        return precio * 0.9;
    }
 
}
 
class DescuentoVIP implements Descuento {
 
    public double aplicar(double precio) {
        return precio * 0.8;
    }
 
}
 
class CalculadoraDescuentos {
 
    public double calcular(Descuento descuento, double precio) {
        return descuento.aplicar(precio);
    }
 
}
 

Liskov Substitution Principle (LSP)

Definición

Las clases hijas deben poder reemplazar a su clase padre sin romper el programa.

Esto significa que una subclase debe respetar el comportamiento esperado de la clase base.

Ejemplo:

class Pajaro {
 
    public void volar() {
        System.out.println("Volando");
    }
 
}
 
class Pinguino extends Pajaro {
 
    @Override
    public void volar() {
        throw new UnsupportedOperationException();
    }
 
}
// Un pingüino no vuela, por lo que rompe el contrato de la clase padre.

Solución

  • Separar comportamientos
interface Volador {
 
    void volar();
 
}
class Aguila implements Volador {
 
    public void volar() {
        System.out.println("El águila vuela");
    }
 
}
class Pinguino {
 
    public void nadar() {
        System.out.println("El pingüino nada");
    }
 
}
 

Interface Segregation Principle (ISP)

Definición

Ninguna clase debe verse obligada a implementar métodos que no necesita.

Es mejor tener muchas interfaces pequeñas que una interfaz gigante.

Ejemplo

interface Trabajador {
 
    void trabajar();
    void comer();
    void dormir();
 
}
 
class Robot implements Trabajador {
 
    public void trabajar() {}
 
    public void comer() {
        throw new UnsupportedOperationException();
    }
 
    public void dormir() {
        throw new UnsupportedOperationException();
    }
 
}
 
// El robot no come ni duerme

Solución

Separar interfaces.

interface Trabajador {
 
    void trabajar();
 
}
interface SerVivo {
 
    void comer();
    void dormir();
 
}
 
class Humano implements Trabajador, SerVivo {
 
    public void trabajar() {}
 
    public void comer() {}
 
    public void dormir() {}
 
}
 
class Robot implements Trabajador {
 
    public void trabajar() {}
 
}
// Ahora cada clase implementa solo lo que necesita.

Dependency Inversion Principle (DIP)

Definición

Los módulos de alto nivel no deben depender de módulos de bajo nivel.

Ambos deben depender de abstracciones.

Y además:

Las abstracciones no deben depender de detalles.

Ejemplo:

class MySQLDatabase {
 
    public void guardar(String dato) {
        System.out.println("Guardando en MySQL");
    }
 
}
 
class UsuarioService {
 
    private MySQLDatabase database = new MySQLDatabase();
 
    public void guardarUsuario(String nombre) {
        database.guardar(nombre);
    }
 
}
 

Problema:

UsuarioService depende directamente de MySQL.

Si cambiamos la base de datos, hay que modificar la clase.

Solución

interface Database {
 
    void guardar(String dato);
 
}
 
class MongoDatabase implements Database {
 
    public void guardar(String dato) {
        System.out.println("Guardando en MongoDB");
    }
 
}
 
class UsuarioService {
 
    private Database database;
 
    public UsuarioService(Database database) {
        this.database = database;
    }
 
    public void guardarUsuario(String nombre) {
        database.guardar(nombre);
    }
 
}
 
Database db = new MySQLDatabase();
UsuarioService service = new UsuarioService(db);
 
service.guardarUsuario("Alejandro");