Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Programación Orientada a Objetos

Diseño por Contratos

Especificaciones Formales para Software Confiable

Universidad Nacional de Rio Negro - Sede Andina

En los capítulos anteriores construimos objetos con buena estructura (Fundamentos de la Programación Orientada a Objetos), establecimos relaciones claras (Encapsulamiento y Relaciones entre Objetos), y aprendimos sobre herencia y polimorfismo (Herencia y Polimorfismo Conceptual y Herencia y Polimorfismo en Java). Pero, ¿cómo garantizamos que esos objetos se comporten correctamente? ¿Cómo especificamos qué espera un método y qué promete entregar?

El Diseño por Contratos (Design by Contract, DbC) es una metodología que responde estas preguntas estableciendo obligaciones y garantías formales entre los objetos que colaboran. Este enfoque se relaciona estrechamente con el Principio de Sustitución de Liskov (LSP) y con el manejo de Excepciones Orientadas a Objetos.


La Filosofía del Contrato

Imaginá un contrato entre un cliente y un proveedor de servicios:

En software, los métodos establecen contratos similares:

Origen: Bertrand Meyer y Eiffel

El Diseño por Contratos fue formalizado por Bertrand Meyer en los años 80, como parte del lenguaje de programación Eiffel. Meyer se inspiró en la lógica de Hoare y las especificaciones formales.

Beneficios del Diseño por Contratos

  1. Documentación ejecutable: Los contratos son especificaciones que se pueden verificar

  2. Depuración más fácil: Las violaciones indican exactamente dónde está el error

  3. Responsabilidades claras: Se sabe quién falló (cliente o proveedor)

  4. Diseño más robusto: Fuerza a pensar en casos límite

  5. Herencia segura: Define reglas para especialización correcta


Precondiciones: Lo que el Cliente Debe Garantizar

Definición

Una precondición es una condición que debe ser verdadera antes de que se ejecute un método. Es la obligación del cliente (quien llama al método) garantizar que se cumple.

Precondición de un método: qué debe garantizar el cliente antes de invocarlo.

Figure 1:Precondición de un método: qué debe garantizar el cliente antes de invocarlo.

Ejemplos de Precondiciones

División

/**
 * Divide dos números.
 * 
 * @precondition divisor != 0
 */
double dividir(double dividendo, double divisor) {
    // Si divisor es 0, el cliente violó el contrato
    return dividendo / divisor;
}

Acceso a Colección

/**
 * Obtiene el elemento en la posición indicada.
 * 
 * @precondition indice >= 0
 * @precondition indice < tamaño()
 */
Elemento obtener(int indice) {
    return elementos[indice];
}

Transferencia Bancaria

/**
 * Transfiere dinero a otra cuenta.
 * 
 * @precondition monto > 0
 * @precondition monto <= saldo
 * @precondition cuentaDestino != null
 * @precondition cuentaDestino.estaActiva()
 */
void transferir(double monto, Cuenta cuentaDestino) {
    this.saldo -= monto;
    cuentaDestino.saldo += monto;
}

Verificación de Precondiciones

Las precondiciones pueden verificarse de varias formas:

Con Assertions (desarrollo)

void transferir(double monto, Cuenta destino) {
    assert monto > 0 : "Monto debe ser positivo";
    assert monto <= saldo : "Fondos insuficientes";
    assert destino != null : "Cuenta destino requerida";
    
    // ... lógica
}

Con Validación Explícita

void transferir(double monto, Cuenta destino) {
    if (monto <= 0) {
        throw new IllegalArgumentException("Monto debe ser positivo");
    }
    if (monto > saldo) {
        throw new IllegalArgumentException("Fondos insuficientes");
    }
    if (destino == null) {
        throw new NullPointerException("Cuenta destino requerida");
    }
    
    // ... lógica
}

Con Método de Validación

void transferir(double monto, Cuenta destino) {
    validarTransferencia(monto, destino);
    
    // ... lógica
}

private void validarTransferencia(double monto, Cuenta destino) {
    // Centraliza las validaciones
}

¿Quién Verifica las Precondiciones?

Esta es una pregunta de diseño importante:

EnfoqueVentajasDesventajas
El cliente verificaEvita llamadas innecesariasCódigo duplicado si hay múltiples clientes
El método verificaDefensa en profundidadOverhead en cada llamada
Ambos verificanMáxima seguridadRedundancia, más código

Postcondiciones: Lo que el Método Garantiza

Definición

Una postcondición es una condición que debe ser verdadera después de que se ejecute un método (asumiendo que las precondiciones se cumplieron). Es la obligación del proveedor (el método) garantizar que se cumple.

Ejemplos de Postcondiciones

Raíz Cuadrada

/**
 * Calcula la raíz cuadrada de un número.
 * 
 * @precondition numero >= 0
 * @postcondition resultado >= 0
 * @postcondition resultado * resultado ≈ numero (con tolerancia)
 */
double raizCuadrada(double numero) {
    // Implementación...
    return resultado;
}

Agregar a Lista

/**
 * Agrega un elemento al final de la lista.
 * 
 * @precondition elemento != null
 * @postcondition tamaño() == old(tamaño()) + 1
 * @postcondition obtener(tamaño() - 1) == elemento
 * @postcondition contiene(elemento) == true
 */
void agregar(Elemento elemento) {
    // Implementación...
}

Ordenar Lista

/**
 * Ordena la lista en orden ascendente.
 * 
 * @postcondition para todo i en [0, tamaño()-1): 
 *                obtener(i) <= obtener(i+1)
 * @postcondition tamaño() == old(tamaño())
 * @postcondition contiene exactamente los mismos elementos que antes
 */
void ordenar() {
    // Implementación...
}

Transferencia Bancaria (completo)

/**
 * Transfiere dinero a otra cuenta.
 * 
 * @precondition monto > 0
 * @precondition monto <= saldo
 * @precondition destino != null
 * 
 * @postcondition this.saldo == old(this.saldo) - monto
 * @postcondition destino.saldo == old(destino.saldo) + monto
 * @postcondition old(this.saldo) + old(destino.saldo) == 
 *                this.saldo + destino.saldo  // Conservación del dinero
 */
void transferir(double monto, Cuenta destino) {
    this.saldo -= monto;
    destino.saldo += monto;
}

Postcondiciones Excepcionales

¿Qué pasa cuando un método puede fallar legítimamente (no por violación de contrato)?

/**
 * Lee el contenido de un archivo.
 * 
 * @precondition ruta != null
 * @precondition ruta no está vacía
 * 
 * @postcondition.normal resultado contiene el contenido del archivo
 * @postcondition.excepcional si archivo no existe: 
 *                            lanza ArchivoNoEncontradoException
 * @postcondition.excepcional si no hay permisos: 
 *                            lanza PermisoNegadoException
 */
String leerArchivo(String ruta) throws ArchivoNoEncontradoException, 
                                       PermisoNegadoException {
    // ...
}

Invariantes de Clase: Lo que Siempre Debe Ser Verdad

Definición

Un invariante de clase es una condición que siempre debe ser verdadera para todas las instancias de una clase, en todo momento observable (entre llamadas a métodos públicos).

Invariante de clase: condiciones que toda instancia debe preservar en cada estado observable.

Figure 2:Invariante de clase: condiciones que toda instancia debe preservar en cada estado observable.

Ejemplos de Invariantes

Cuenta Bancaria

/**
 * Representa una cuenta bancaria.
 * 
 * @invariant saldo >= 0
 * @invariant numero != null && numero.length() == 20
 * @invariant titular != null
 * @invariant fechaApertura <= fechaActual
 */
class CuentaBancaria {
    private double saldo;
    private String numero;
    private Cliente titular;
    private LocalDate fechaApertura;
    
    // Todos los métodos deben preservar estos invariantes
}

Fracción

/**
 * Representa una fracción matemática.
 * 
 * @invariant denominador != 0
 * @invariant denominador > 0  // Normalizamos: signo en numerador
 * @invariant mcd(|numerador|, denominador) == 1  // Siempre simplificada
 */
class Fraccion {
    private int numerador;
    private int denominador;
    
    Fraccion(int num, int den) {
        // Debe establecer el invariante
        if (den == 0) throw new IllegalArgumentException("Denominador cero");
        
        // Normalizar signo
        if (den < 0) {
            num = -num;
            den = -den;
        }
        
        // Simplificar
        int divisor = mcd(Math.abs(num), den);
        this.numerador = num / divisor;
        this.denominador = den / divisor;
    }
    
    Fraccion sumar(Fraccion otra) {
        // Debe preservar el invariante en el resultado
        return new Fraccion(
            this.numerador * otra.denominador + otra.numerador * this.denominador,
            this.denominador * otra.denominador
        );
    }
}

Intervalo

/**
 * Representa un intervalo cerrado [inicio, fin].
 * 
 * @invariant inicio <= fin
 */
class Intervalo {
    private double inicio;
    private double fin;
    
    Intervalo(double inicio, double fin) {
        if (inicio > fin) {
            throw new IllegalArgumentException("Inicio debe ser <= fin");
        }
        this.inicio = inicio;
        this.fin = fin;
    }
    
    double longitud() {
        return fin - inicio;  // Siempre >= 0 por invariante
    }
    
    boolean contiene(double valor) {
        return valor >= inicio && valor <= fin;
    }
}

Lista Ordenada

/**
 * Lista que mantiene sus elementos ordenados.
 * 
 * @invariant para todo i en [0, tamaño()-1): 
 *            obtener(i) <= obtener(i+1)
 */
class ListaOrdenada<T extends Comparable<T>> {
    private List<T> elementos;
    
    void agregar(T elemento) {
        // No puede simplemente agregar al final
        // Debe insertar en la posición correcta para mantener el invariante
        int pos = encontrarPosicion(elemento);
        elementos.add(pos, elemento);
    }
}

Invariantes y Momentos de Verificación

El invariante no necesita ser verdadero en todo instante, sino en momentos observables:

void transferir(double monto, Cuenta destino) {
    // INVARIANTE: saldo >= 0 ✓ (verdadero al entrar)
    
    this.saldo -= monto;
    // MOMENTO INTERMEDIO: ¡invariante podría ser falso temporalmente!
    // Esto es aceptable porque es inobservable
    
    destino.saldo += monto;
    
    // INVARIANTE: saldo >= 0 ✓ (debe ser verdadero al salir)
}

El Contrato Completo

Estructura de un Contrato

Un contrato completo tiene tres partes:

Ejemplo Completo: Pila con Contratos

/**
 * Pila LIFO (Last In, First Out) con capacidad limitada.
 * 
 * @invariant tamaño() >= 0
 * @invariant tamaño() <= capacidad()
 * @invariant capacidad() > 0
 * @invariant estaVacia() == (tamaño() == 0)
 * @invariant estaLlena() == (tamaño() == capacidad())
 */
class Pila<T> {
    private T[] elementos;
    private int tope;
    private int capacidad;
    
    /**
     * Crea una pila con la capacidad especificada.
     * 
     * @precondition capacidad > 0
     * @postcondition tamaño() == 0
     * @postcondition capacidad() == capacidad
     * @postcondition estaVacia() == true
     */
    Pila(int capacidad) {
        if (capacidad <= 0) {
            throw new IllegalArgumentException("Capacidad debe ser positiva");
        }
        this.elementos = (T[]) new Object[capacidad];
        this.tope = -1;
        this.capacidad = capacidad;
    }
    
    /**
     * Apila un elemento.
     * 
     * @precondition elemento != null
     * @precondition !estaLlena()
     * @postcondition tamaño() == old(tamaño()) + 1
     * @postcondition tope() == elemento
     * @postcondition !estaVacia()
     */
    void apilar(T elemento) {
        if (elemento == null) {
            throw new NullPointerException("Elemento no puede ser null");
        }
        if (estaLlena()) {
            throw new IllegalStateException("Pila llena");
        }
        elementos[++tope] = elemento;
    }
    
    /**
     * Desapila y retorna el elemento del tope.
     * 
     * @precondition !estaVacia()
     * @postcondition tamaño() == old(tamaño()) - 1
     * @postcondition resultado == old(tope())
     * @postcondition !estaLlena()
     */
    T desapilar() {
        if (estaVacia()) {
            throw new IllegalStateException("Pila vacía");
        }
        T elemento = elementos[tope];
        elementos[tope--] = null;  // Ayuda al GC
        return elemento;
    }
    
    /**
     * Consulta el elemento del tope sin removerlo.
     * 
     * @precondition !estaVacia()
     * @postcondition resultado == el último elemento apilado
     * @postcondition tamaño() == old(tamaño())  // No modifica
     */
    T tope() {
        if (estaVacia()) {
            throw new IllegalStateException("Pila vacía");
        }
        return elementos[tope];
    }
    
    /**
     * @postcondition resultado == cantidad de elementos en la pila
     */
    int tamaño() {
        return tope + 1;
    }
    
    /**
     * @postcondition resultado == (tamaño() == 0)
     */
    boolean estaVacia() {
        return tope < 0;
    }
    
    /**
     * @postcondition resultado == (tamaño() == capacidad())
     */
    boolean estaLlena() {
        return tope >= capacidad - 1;
    }
}

Responsabilidades: Cliente vs Proveedor

El Modelo Cliente-Proveedor

¿Qué Pasa Cuando se Viola el Contrato?

ViolaciónResponsableAcción
Precondición no cumplidaClienteBug en el código que llama
Postcondición no cumplidaProveedorBug en el método
Invariante violadoProveedorBug en la clase

Ejemplo: Identificando Responsables

class Calculadora {
    /**
     * @precondition b != 0
     * @postcondition resultado * b == a (aproximadamente)
     */
    double dividir(double a, double b) {
        return a / b;
    }
}

// Escenario 1: Cliente viola precondición
Calculadora calc = new Calculadora();
double resultado = calc.dividir(10, 0);  // ❌ ERROR DEL CLIENTE
// El cliente debería haber verificado que b != 0

// Escenario 2: Proveedor viola postcondición (hipotético bug)
double dividir(double a, double b) {
    return a + b;  // ❌ ERROR DEL PROVEEDOR - retorna suma, no división
}

Programación Defensiva vs Diseño por Contratos

Hay dos filosofías diferentes:

Programación Defensiva: “No confío en nadie”

void procesar(String dato) {
    if (dato == null) {
        dato = "";  // "Corrijo" silenciosamente
    }
    if (dato.isEmpty()) {
        return;  // No hago nada
    }
    // Proceso...
}

Diseño por Contratos: “Cumplí tu parte”

/**
 * @precondition dato != null
 * @precondition !dato.isEmpty()
 */
void procesar(String dato) {
    assert dato != null && !dato.isEmpty();
    // Proceso... si falla, es culpa del cliente
}

Contratos y Herencia: El Principio de Liskov Revisitado

Reglas para Subtipos

Cuando una subclase sobrescribe un método, debe respetar ciertas reglas para mantener la sustituibilidad:

Ejemplo: Precondiciones Más Débiles (Correcto)

class Calculadora {
    /**
     * @precondition numero >= 0
     */
    double raiz(double numero) {
        return Math.sqrt(numero);
    }
}

class CalculadoraCientifica extends Calculadora {
    /**
     * @precondition numero puede ser cualquier valor (incluidos negativos)
     * 
     * Precondición MÁS DÉBIL: acepta más casos ✓
     */
    @Override
    double raiz(double numero) {
        if (numero >= 0) {
            return Math.sqrt(numero);
        } else {
            // Retorna parte imaginaria (números complejos)
            return Math.sqrt(-numero);  // Simplificado
        }
    }
}

// Código cliente que usa Calculadora
void procesar(Calculadora calc) {
    double r = calc.raiz(4);  // Funciona con ambas
}
// Si recibe CalculadoraCientifica, sigue funcionando ✓

Ejemplo: Precondiciones Más Fuertes (Incorrecto)

class Coleccion {
    /**
     * @precondition elemento != null
     */
    void agregar(Object elemento) {
        // ...
    }
}

class ColeccionEstricta extends Coleccion {
    /**
     * @precondition elemento != null
     * @precondition elemento instanceof String  // ❌ MÁS FUERTE
     */
    @Override
    void agregar(Object elemento) {
        if (!(elemento instanceof String)) {
            throw new IllegalArgumentException("Solo Strings");
        }
        // ...
    }
}

// Código cliente
void llenar(Coleccion col) {
    col.agregar(42);  // Válido según contrato de Coleccion
}

// Si recibe ColeccionEstricta, ¡falla! ❌
// Viola el principio de sustitución

Ejemplo: Postcondiciones Más Fuertes (Correcto)

class Buscador {
    /**
     * @postcondition resultado contiene elementos que matchean
     */
    List<Resultado> buscar(String query) {
        // Búsqueda básica
        return resultados;
    }
}

class BuscadorOrdenado extends Buscador {
    /**
     * @postcondition resultado contiene elementos que matchean
     * @postcondition resultado está ordenado por relevancia  // MÁS FUERTE ✓
     */
    @Override
    List<Resultado> buscar(String query) {
        List<Resultado> resultados = super.buscar(query);
        Collections.sort(resultados, porRelevancia);
        return resultados;
    }
}

// El código cliente espera resultados, y recibe resultados ORDENADOS
// Más de lo esperado: ✓ compatible

Relación con Covarianza y Contravarianza

Las reglas de contratos en herencia se relacionan con:

class Animal {
    /**
     * @precondition comida instanceof Alimento
     * @postcondition estado es mejor o igual
     */
    void comer(Alimento comida) { }
}

class Gato extends Animal {
    /**
     * @precondition comida instanceof Alimento (o más general)
     * @postcondition estado es mejor (más específico) ✓
     */
    @Override
    void comer(Alimento comida) {
        // Puede aceptar cualquier Alimento
        // Pero garantiza mejora específica
    }
}

Contratos en la Práctica

Documentación de Contratos

Los contratos se documentan típicamente en los comentarios:

/**
 * Calcula el factorial de un número.
 * 
 * <p>El factorial de n (n!) es el producto de todos los
 * enteros positivos menores o iguales a n.</p>
 * 
 * @param n el número del cual calcular el factorial
 * @return el factorial de n
 * 
 * @precondition n >= 0
 * @precondition n <= 20 (para evitar overflow en long)
 * 
 * @postcondition resultado >= 1
 * @postcondition resultado == n * (n-1) * ... * 1 para n > 0
 * @postcondition resultado == 1 para n == 0
 * 
 * @throws IllegalArgumentException si n < 0 o n > 20
 */
long factorial(int n) {
    if (n < 0 || n > 20) {
        throw new IllegalArgumentException("n debe estar en [0, 20]");
    }
    
    long resultado = 1;
    for (int i = 2; i <= n; i++) {
        resultado *= i;
    }
    return resultado;
}

Uso de Assertions en Java

Java provee assert para verificar condiciones:

void metodo(int valor) {
    // Precondición
    assert valor > 0 : "valor debe ser positivo";
    
    // ... lógica ...
    
    int resultado = calcular();
    
    // Postcondición
    assert resultado >= 0 : "resultado no puede ser negativo";
}

Bibliotecas para Contratos

Existen bibliotecas que facilitan la verificación de contratos:

Google Guava (Preconditions)

import static com.google.common.base.Preconditions.*;

void transferir(double monto, Cuenta destino) {
    checkArgument(monto > 0, "Monto debe ser positivo: %s", monto);
    checkArgument(monto <= saldo, "Fondos insuficientes");
    checkNotNull(destino, "Cuenta destino requerida");
    
    // ...
}

Apache Commons (Validate)

import org.apache.commons.lang3.Validate;

void transferir(double monto, Cuenta destino) {
    Validate.isTrue(monto > 0, "Monto debe ser positivo");
    Validate.isTrue(monto <= saldo, "Fondos insuficientes");
    Validate.notNull(destino, "Cuenta destino requerida");
    
    // ...
}

Testing y Contratos

Los contratos guían la escritura de tests:

// Test de precondiciones (casos límite inválidos)
@Test(expected = IllegalArgumentException.class)
void dividir_divisorCero_lanzaExcepcion() {
    calculadora.dividir(10, 0);
}

// Test de postcondiciones (verificar garantías)
@Test
void dividir_valoresValidos_resultadoCorrecto() {
    double resultado = calculadora.dividir(10, 2);
    assertEquals(5.0, resultado, 0.001);
    assertEquals(10.0, resultado * 2, 0.001);  // Verifica postcondición
}

// Test de invariantes (estado consistente)
@Test
void pila_operacionesVarias_invariantesPreservados() {
    Pila<Integer> pila = new Pila<>(10);
    
    pila.apilar(1);
    pila.apilar(2);
    assertTrue(pila.tamaño() >= 0);
    assertTrue(pila.tamaño() <= pila.capacidad());
    
    pila.desapilar();
    assertTrue(pila.tamaño() >= 0);
    assertTrue(pila.tamaño() <= pila.capacidad());
}

El Problema del Null y los Contratos

Null como Fuente de Violaciones

null es una fuente constante de violaciones de contrato:

// ¿Qué significa retornar null?
Usuario buscar(String id) {
    // ¿null significa "no encontrado"?
    // ¿O significa "error"?
    // ¿O el id era inválido?
}

// ¿Qué pasa si el parámetro es null?
void procesar(Usuario usuario) {
    usuario.getNombre();  // NullPointerException
}

Estrategias para Manejar Null

1. Prohibir null explícitamente

/**
 * @precondition usuario != null
 * @postcondition resultado != null
 */
String formatear(Usuario usuario) {
    Objects.requireNonNull(usuario, "Usuario no puede ser null");
    return usuario.getNombre() + " - " + usuario.getEmail();
}

2. Usar Optional

/**
 * @postcondition resultado.isPresent() si el usuario existe
 * @postcondition resultado.isEmpty() si no existe
 */
Optional<Usuario> buscar(String id) {
    Usuario u = baseDatos.buscar(id);
    return Optional.ofNullable(u);
}

// Uso
Optional<Usuario> usuario = buscar("123");
usuario.ifPresent(u -> System.out.println(u.getNombre()));
String nombre = usuario.map(Usuario::getNombre).orElse("Anónimo");

3. Patrón Null Object

interface Usuario {
    String getNombre();
    boolean esReal();
}

class UsuarioReal implements Usuario {
    String getNombre() { return this.nombre; }
    boolean esReal() { return true; }
}

class UsuarioNulo implements Usuario {
    String getNombre() { return "Anónimo"; }
    boolean esReal() { return false; }
}

// Nunca retorna null
Usuario buscar(String id) {
    Usuario u = baseDatos.buscar(id);
    return u != null ? u : new UsuarioNulo();
}

Resumen

Elementos del Contrato

ElementoResponsableCuándo se Verifica
PrecondiciónClienteAntes del método
PostcondiciónProveedorDespués del método
InvarianteClaseAntes y después de métodos públicos

Reglas en Herencia

ElementoRegla en Subclases
PrecondicionesIguales o más débiles
PostcondicionesIguales o más fuertes
InvariantesSe heredan y pueden agregarse

Beneficios Clave

  1. Claridad: Responsabilidades explícitas

  2. Robustez: Errores detectados temprano

  3. Documentación: Especificación ejecutable

  4. Debugging: Localización precisa de fallos

  5. Diseño: Fuerza a pensar en casos límite


Ejercicios

Próximo paso

Para seguir, conviene pasar a el material siguiente, donde el recorrido continúa sobre esta base.