Diseño por Contratos
Especificaciones Formales para Software Confiable
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¶
La Metáfora del Contrato Legal¶
Imaginá un contrato entre un cliente y un proveedor de servicios:
En software, los métodos establecen contratos similares:
Precondiciones: Lo que el cliente (quien llama) debe garantizar
Postcondiciones: Lo que el proveedor (el método) garantiza si se cumplen las precondiciones
Invariantes: Condiciones que siempre deben ser verdaderas
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¶
Documentación ejecutable: Los contratos son especificaciones que se pueden verificar
Depuración más fácil: Las violaciones indican exactamente dónde está el error
Responsabilidades claras: Se sabe quién falló (cliente o proveedor)
Diseño más robusto: Fuerza a pensar en casos límite
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.
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:
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| El cliente verifica | Evita llamadas innecesarias | Código duplicado si hay múltiples clientes |
| El método verifica | Defensa en profundidad | Overhead en cada llamada |
| Ambos verifican | Máxima seguridad | Redundancia, 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).
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ón | Responsable | Acción |
|---|---|---|
| Precondición no cumplida | Cliente | Bug en el código que llama |
| Postcondición no cumplida | Proveedor | Bug en el método |
| Invariante violado | Proveedor | Bug 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ónEjemplo: 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: ✓ compatibleRelación con Covarianza y Contravarianza¶
Las reglas de contratos en herencia se relacionan con:
Contravarianza de precondiciones: Los parámetros pueden ser más generales
Covarianza de postcondiciones: Los resultados pueden ser más específicos
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¶
| Elemento | Responsable | Cuándo se Verifica |
|---|---|---|
| Precondición | Cliente | Antes del método |
| Postcondición | Proveedor | Después del método |
| Invariante | Clase | Antes y después de métodos públicos |
Reglas en Herencia¶
| Elemento | Regla en Subclases |
|---|---|
| Precondiciones | Iguales o más débiles |
| Postcondiciones | Iguales o más fuertes |
| Invariantes | Se heredan y pueden agregarse |
Beneficios Clave¶
Claridad: Responsabilidades explícitas
Robustez: Errores detectados temprano
Documentación: Especificación ejecutable
Debugging: Localización precisa de fallos
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.