Introducción
En 1872, el matemático Felix Klein se enfrentaba a un problema que mantenía en desacuerdo a los matemáticos de su época: habían proliferado geometrías distintas, la euclidiana, la proyectiva, la hiperbólica, y nadie sabía muy bien cómo relacionarlas entre sí.
Su propuesta, presentada en lo que hoy conocemos como el Programa de Erlangen, fue reformular la pregunta. En vez de preguntar qué objetos tiene una geometría, preguntó: ¿Qué propiedades permanecen iguales cuando se aplican sus transformaciones? Una geometría, argumentó Klein, queda definida por aquello que no cambia.
El término invariante ya existía, acuñado por los matemáticos británicos Boole, Cayley y Sylvester en la década de 1850 para describir expresiones algebraicas que sobreviven a ciertas transformaciones de variables. Klein lo tomó prestado y lo convirtió en el principio organizador para clasificar familias enteras de geometrías, una idea que resultó influyente bien más allá de las matemáticas.
Un siglo después, ese mismo principio apareció en tres lugares distintos de la ingeniería de software:
En los compiladores, que detectan expresiones cuyo valor no cambia entre iteraciones de un bucle y las mueven fuera para evitar recalcularlas.
En el testing, cuando en lugar de verificar casos concretos se verifica que una propiedad se sostiene para cualquier entrada posible.
En el diseño de software, cuando se define que ciertas reglas del dominio deben ser verdaderas siempre, antes y después de cualquier operación.
Los tres usos comparten la misma pregunta de fondo: ¿qué es lo que no puede cambiar aquí? Este artículo recorre ese linaje y explora cómo responder esa pregunta con precisión es, en los tres casos, el paso intelectual más importante.
Origen del término
El Programa de Erlangen parte de una pregunta concreta: ¿qué propiedades sobreviven a cada tipo de transformación geométrica? Rotar, escalar, proyectar, reflejar — cada operación transforma las figuras de algún modo.

En geometría euclidiana (la del mundo cotidiano), las principales transformaciones que podemos encontrar son rotaciones, traslaciones y reflexiones. Bajo estas operaciones, las longitudes y los ángulos se preservan: un triángulo rotado sigue teniendo los mismos lados y ángulos. Esas son las invariantes euclidianas.

En geometría proyectiva (la que describe cómo vemos los objetos en perspectiva, donde las líneas paralelas parecen converger en el horizonte), las transformaciones son mucho más agresivas: las longitudes y los ángulos sí cambian. Sin embargo, hay una propiedad más abstracta que sobrevive: la razón cruzada (cross-ratio). Si tomamos cuatro puntos alineados A, B, C, D, su razón cruzada es un cociente calculado a partir de las distancias entre ellos. No importa cómo se deforme la figura por la proyección: esta razón siempre se mantiene igual. Es la invariante que define la geometría proyectiva.

Lo revolucionario de Klein fue proponer que lo que define a una geometría no son sus objetos, sino sus invariantes: aquello que permanece sin cambiar cuando se aplican las transformaciones del grupo. (Para profundizar en el Programa de Erlangen y la razón cruzada, véanse las referencias [4] y [5]; para una introducción accesible a la razón cruzada, la entrada de Wikipedia sobre Cross-ratio [6] ofrece un buen punto de partida.)
Es importante notar que el Programa de Erlangen no usa el término invariante exactamente en el sentido de la teoría de grupos moderna. Klein hablaba de propiedades preservadas bajo grupos de simetrías, lo cual es conceptualmente cercano pero formalmente distinto. Sin embargo, la intuición central; identificar qué no cambia para entender un sistema; es precisamente la que se trasladó a la informática.
Un siglo después, Robert Floyd (1967, Assigning Meanings to Programs) y Tony Hoare (1969, An Axiomatic Basis for Computer Programming) tomaron esta intuición y la formalizaron para razonar sobre el correcto comportamiento de los programas de computadora. Hoare introdujo la triple de Hoare {P} C {Q}; precondición, comando, postcondición; y el concepto de loop invariant; una condición que es verdadera antes del loop, se mantiene verdadera en cada iteración, y sigue siendo verdadera al terminar. La transformación de Klein se convierte aquí en cada iteración del loop; la invariante es lo que sobrevive a todas las iteraciones.

Conviene aclarar una distinción importante: las invariantes de Floyd y Hoare tienen un propósito de verificación lógica; demostrar que un programa hace lo que debe hacer. Las invariantes que detectan los compiladores (como veremos mas adelante) tienen un propósito de optimización; detectar que algo no cambia para evitar recalcularlo. Ambas comparten la definición esencial algo que no cambia entre iteraciones, pero se usan con fines diferentes. La conexión entre ambas es conceptual, no técnica. Klein inspiró una forma de pensar que Floyd y Hoare formalizaron para la lógica, y que los ingenieros de compiladores adoptaron independientemente para la optimización.
En 1988, Bertrand Meyer llevó las invariantes de la lógica formal al diseño orientado a objetos con su libro Object-Oriented Software Construction y el paradigma de Design by Contract donde cada clase tiene invariantes que restringen su estado y deben mantenerse entre toda operación pública.

Desde Klein hasta Meyer, el hilo conductor es el mismo: identificar qué no cambia es la forma más poderosa de entender un sistema, ya sea geométrico, lógico o de software. Que más de un siglo de trabajo en matemáticas, lógica y ciencia de la computación converja en una misma intuición dice algo sobre la solidez de la idea.
Este artículo explora los tres usos principales del concepto en ingeniería de software, cómo se relacionan entre sí, y ofrece referencias para profundizar en cada uno.
1. Invariantes en Compiladores
¿Qué es una invariante de loop?
Cuando un compilador analiza un bucle, busca expresiones cuyo valor no cambia entre iteraciones. Estas expresiones se denominan loop-invariant y pueden moverse fuera del bucle sin alterar el comportamiento del programa. La técnica que realiza este movimiento se conoce como Loop-Invariant Code Motion o LICM, también llamada hoisting.
Ejemplo de LICM puro
for (int i = 0; i < 1000; i++) {
int x = y * z; // y, z no cambian dentro del loop
a[i] = x + i;
}
El compilador detecta que y * z produce el mismo resultado en cada iteración (ni y ni z se modifican dentro del bucle). Como la expresión es invariante al loop, la mueve fuera:
int x = y * z; // calculado una sola vez
for (int i = 0; i < 1000; i++) {
a[i] = x + i;
}
El resultado es idéntico, pero en vez de 1000 multiplicaciones, se ejecuta solo una. Esto es LICM en su forma más pura: mover un cálculo invariante fuera del bucle.
Ejemplo de Loop Unswitching
Existe una optimización estrechamente relacionada pero técnicamente distinta: Loop Unswitching. Mientras LICM mueve cálculos invariantes fuera del bucle, Loop Unswitching mueve condicionales invariantes fuera del bucle, duplicando el cuerpo del mismo:
int allowed = check_permission();
for (int i = 0; i < 1000; i++) {
if (allowed) { // "allowed" no cambia dentro del loop
do_something(i);
} else {
do_something_else(i);
}
}
El compilador detecta que allowed es invariante al loop y transforma el código en:
int allowed = check_permission();
if (allowed) {
for (int i = 0; i < 1000; i++) do_something(i);
} else {
for (int i = 0; i < 1000; i++) do_something_else(i);
}
Ambas técnicas, LICM y Loop Unswitching, están impulsadas por el mismo análisis: detectar qué es invariante dentro del loop. La diferencia es qué hacen con esa información. LICM mueve expresiones; Loop Unswitching mueve condiciones y duplica el loop. En la literatura de compiladores se tratan como optimizaciones distintas (véanse las referencias [7] y [10]), pero comparten la misma raíz: el concepto de invariante.

Figura 3: Optimización de Loop Unswitching. El compilador detecta una condición invariante dentro de un bucle y lo divide en dos bucles separados para evitar evaluaciones redundantes en cada iteración.
Una precisión técnica importante:
Para ser formalmente rigurosos, no es la condición en abstracto la que debe ser invariante, sino el valor de la expresión usada en la rama respecto del bucle.
Además, el compilador no aplica esta transformación siempre, sino solo cuando determina que es seguro y rentable, porque duplica código y puede aumentar el tamaño del binario.
Finalmente, siempre debe preservarse la semántica estricta del programa: si el valor de la condición puede cambiar por efectos laterales, aliasing de punteros, memoria volátil o excepciones, el compilador no podrá aplicar esta optimización tan libremente.
¿Cómo detecta el compilador una invariante?
El compilador utiliza un análisis de definiciones alcanzables; examina todas las asignaciones que podrían llegar a los operandos de una expresión. Si todas esas asignaciones están fuera del loop, la expresión es invariante y puede moverse. Este análisis es parte de las técnicas de análisis de flujo de datos que se estudian en teoría de compiladores.
Límites de la optimización
El compilador solo puede mover código que pueda demostrar como invariante. Hay varios escenarios donde no puede:
Variables globales y punteros: Si la variable es global o se accede a través de un puntero, el compilador no puede garantizar que una llamada a función dentro del loop no la modifique. Por precaución, se abstiene de optimizar.
La palabra clave volatile: En algunos lenguajes, marcar una variable como volatile le dice al compilador: "no asumas que este valor es estable; léelo de memoria cada vez". Esto es necesario en dos contextos principales. En programación de hardware, un registro mapeado en memoria puede cambiar su valor por acción del dispositivo, no del programa, y la lectura debe ocurrir en cada iteración para reflejar el estado real del hardware. En programación concurrente (antes de los modelos de memoria modernos como std::atomic en C++11, por ejemplo), volatile se usaba como un mecanismo para señalizar entre hilos. Hoy se considera insuficiente para este propósito y volatile se reserva casi exclusivamente para I/O mapeado en memoria. Para concurrencia se usan atómicos y barreras de memoria.
Herramientas para observar LICM
En C se puede ver el código máquina generado con gcc -O2 -S archivo.c. Lo que se espera ver es si la instrucción de multiplicación o la operación invariante aparece antes de la etiqueta del loop en el ensamblador generado, en lugar de aparecer dentro del cuerpo del loop. Por ejemplo, si y * z era invariante, verás una instrucción imull o mulsd ejecutándose una sola vez antes del salto condicional que marca el inicio de las iteraciones, en vez de repetirse dentro del bloque que se ejecuta en cada iteración.
Ejemplo:
// main.c
#include <stdio.h>
void fill(int *a, int y, int z, int n) {
for (int i = 0; i < n; i++) {
int x = y * z;
a[i] = x + i;
}
}
int main() {
int a[1000];
fill(a, 7, 13, 1000);
printf("%d\n", a[999]);
return 0;
}
Obtenemos el código ensamblador con gcc -02 -S main.c
$ gcc -O2 -S main.c
$ cat main.s
...
fill:
.LFB11:
.cfi_startproc
testl %ecx, %ecx ; ¿n <= 0?
jle .L1 ; si sí, retorna
imull %edx, %esi ; y * z ← ¡ANTES del loop!
addl %esi, %ecx ; ecx = (y*z) + n (límite del loop)
.p2align 4
.p2align 4
.p2align 3
.L3:
movl %esi, (%rdi) ; escribe el valor actual
addl $1, %esi ; incrementa
addq $4, %rdi ; avanza el puntero
cmpl %ecx, %esi ; ¿llegó al límite?
jne .L3
...
En Go, el flag go build -gcflags="-m -S" muestra las decisiones del compilador. Los mensajes de diagnóstico indicarán qué expresiones fueron movidas fuera del loop.
package main
import "fmt"
//go:noinline
func fill(a []int, y, z, n int) {
for i := 0; i < n; i++ {
x := y * z
a[i] = x + i
}
}
func main() {
a := make([]int, 1000)
fill(a, 7, 13, 1000)
fmt.Println(a[999])
}
Aplicamos go build -gcflags="-S" main.go, y mostrará mucha salida, perol o que nos compete está al principio.
0x0009 00009 (/home/jacobopus/ejemploc/main.go:6) PCDATA $3, $1
0x0009 00009 (/home/jacobopus/ejemploc/main.go:7) XORL CX, CX ; i = 0
0x000b 00011 (/home/jacobopus/ejemploc/main.go:7) JMP 23 ; ir a comparar
0x000d 00013 (/home/jacobopus/ejemploc/main.go:9) MOVQ SI, (AX)(CX*8) ; a[i] = resultado
0x0011 00017 (/home/jacobopus/ejemploc/main.go:7) INCQ CX ; i++
0x0014 00020 (/home/jacobopus/ejemploc/main.go:8) MOVQ DX, SI ; restaura z en SI
0x0017 00023 (/home/jacobopus/ejemploc/main.go:7) CMPQ R8, CX ; i < n?
0x001a 00026 (/home/jacobopus/ejemploc/main.go:7) JLE 45
0x001c 00028 (/home/jacobopus/ejemploc/main.go:8) MOVQ SI, DX ; guarda z
0x001f 00031 (/home/jacobopus/ejemploc/main.go:8) IMULQ DI, SI ; y * z DENTRO DEL LOOP
0x0023 00035 (/home/jacobopus/ejemploc/main.go:9) ADDQ CX, SI ; + i
0x0026 00038 (/home/jacobopus/ejemploc/main.go:9) CMPQ BX, CX
0x0029 00041 (/home/jacobopus/ejemploc/main.go:9) JHI 13
0x002b 00043 (/home/jacobopus/ejemploc/main.go:9) JMP 47
0x002d 00045 (/home/jacobopus/ejemploc/main.go:11) POPQ BP
0x002e 00046 (/home/jacobopus/ejemploc/main.go:11) RET
¡El compilador go no aplicó LICM y no movió la multiplicación y * z fuera del loop! !Evalua la expresión en cada una de las 1000 iteraciones!
Comparado con GCC, que sacó la multiplicación fuera del loop y además aplicó otras optmizaciones, el compilador de Go no lo hizo porque prioriza compilación rápida sobre optimización máxima. Es una decisión de diseño deliberada del equipo de Go.
Para el programador Go esto significa que extraer manualmente x := y *z fuera del loop si tiene impacto real, mientras que con el compilador gcc en C, con -02 es redundante por que el compilador lo hará por nosotros.
Invariantes declaradas por el programador: const y constexpr
En lenguajes compilados con tipado estático, el programador puede declarar explícitamente que un valor es invariante, ayudando tanto al compilador como al lector del código.
En C++, const indica que un valor no cambiará después de su inicialización, lo que permite al compilador optimizar con más confianza. Más poderoso aún es constexpr, que le dice al compilador: "este valor se puede calcular completamente en tiempo de compilación; evalúalo ahora y no en tiempo de ejecución". Un constexpr int MAX_ITEMS = 100; no es una variable que se lee de memoria: es una constante embebida directamente en el código máquina.
En Go, aunque no tiene constexpr, su fuerte sistema de tipado estático actúa como una forma de verificación de invariantes antes de la ejecución. Si una función espera un int y recibes un string, el compilador rechaza el programa antes de ejecutarlo. El sistema de tipos es, en cierto sentido, un verificador de invariantes de tipo.
Estas declaraciones explícitas crean un puente entre las invariantes de compilador y las invariantes de diseño. El programador está expresando contratos sobre qué puede y qué no puede cambiar, y el compilador los hace cumplir.
¿Qué pasa en lenguajes interpretados y con máquina virtual?
No todos los lenguajes se benefician de LICM. La situación varía dependiendo de si el runtime (el programa que ejecuta tu código) incluye un compilador JIT (Just-In-Time: un compilador que genera código máquina mientras el programa se ejecuta, optimizando las partes más usadas) y qué tan sofisticado es.
Tabla comparativa
La columna LICM indica si el runtime es capaz de detectar y mover código invariante fuera de los loops. Sí no significa que ocurra siempre; en compiladores ahead-of-time depende del nivel de optimización, y en JITs solo aplica a código caliente o que el runtime ha detectado como ejecutados con suficiente frecuencia para justificar su compilación a código máquina.
Las notas aclaran las condiciones específicas de cada caso.
Compilados ahead-of-time
| Runtime | LICM | Notas |
|---|---|---|
| C/C++ (gcc, clang) | Sí | Requiere -O2 o superior; no habilitado por defecto |
| Go | Limitado | Habilitado por defecto, pero poco agresivo; como vimos en el ejemplo, puede no aplicarlo |
JIT maduro
| Runtime | LICM | Notas |
|---|---|---|
| Java (HotSpot C2) | Sí, en hot paths | Solo aplica a métodos que alcanzan el compilador optimizador (C2); el código que no alcanza el umbral de calor permanece en el intérprete sin optimizar |
| JavaScript (V8 TurboFan) | Sí, en hot paths | LICM explícito como paso de optimización en TurboFan; puede deoptimizar y volver al intérprete si una suposición de tipo falla |
JIT limitado o sin JIT
| Runtime | LICM | Notas |
|---|---|---|
| Python (PyPy) | Indirecto | El tracing JIT puede lograr un efecto similar a LICM como consecuencia del tracing, pero no lo aplica como paso explícito ni lo garantiza |
| Ruby 3.1+ (YJIT) | No | Optimiza despacho dinámico de métodos, no loops clásicos |
| Ruby 2.6 (MJIT) | Indirecto | Compila métodos calientes a C y delega a GCC/Clang, que pueden aplicar LICM; el beneficio es del compilador C subyacente, no de MJIT |
| Ruby 2.0 (MRI puro) | No | Intérprete puro de bytecode; sin optimizaciones de loop |
| Python (CPython ≤ 3.13) | No | JIT experimental basado en copy-and-patch; sin LICM |
De la optimización a la verificación
Mientras que en compiladores las invariantes se usan para optimizar; si algo no cambia, no tiene sentido recalcularlo; en testing las invariantes sirven para verificar. Si definimos que algo nunca debe cambiar, podemos escribir pruebas que lo confirmen. El mismo concepto, aplicado con un propósito diferente.
2. Invariantes en Testing
¿Qué es una invariante en el contexto de tests?
En testing, una invariante es una condición que debe sostenerse siempre, sin importar los datos de entrada, el orden de ejecución, o las condiciones externas. A diferencia de un test unitario tradicional que verifica un caso específico, e.g. si la entrada es 3, la salida debe ser 9; un test de invariante verifica una propiedad universal; e.g. Para cualquier entrada n ≥ 0, la salida debe ser ≥ 0.
Dos enfoques complementarios
Tests basados en ejemplos (TDD/BDD) se enfocan en lo que cambia: dado un estado X, cuando ocurre Y, el resultado es Z. Son explícitos y fáciles de leer, pero solo cubren los casos que el programador imaginó.
Tests basados en propiedades (Property-Based Testing) se concentran en lo que no cambia: las invariantes del sistema. El framework genera cientos o miles de entradas aleatorias y verifica que las propiedades se mantengan para todas ellas, descubriendo así casos borde que el programador nunca habría anticipado.
Property-Based Testing: QuickCheck y derivados
El pionero es QuickCheck, creado en 1999 para Haskell por Koen Claessen y John Hughes. Su funcionamiento interno es revelador:
Generación: El programador define una propiedad (invariante) y el tipo de datos que acepta. QuickCheck genera automáticamente cientos de valores aleatorios para ese tipo: enteros positivos, negativos, cero, extremos del rango, strings vacíos, strings con caracteres especiales, listas de longitud variable, etc.
Verificación: Para cada valor generado, ejecuta la función bajo prueba y verifica que la propiedad se cumpla.
Shrinking: Si encuentra un caso que falla, no se detiene ahí. Aplica shrinking (reducción): busca automáticamente el contraejemplo más pequeño posible que todavía reproduce la falla. Si el test falló con una lista de 47 elementos, QuickCheck prueba con sublistas cada vez más cortas hasta encontrar la mínima que causa el error. Esto facilita enormemente el diagnóstico.
La potencia del enfoque está en que los generadores aleatorios producen combinaciones de entrada que ningún programador habría escrito manualmente. Bugs que dependen de interacciones sutiles entre valores, un overflow en un caso borde, un off-by-one cuando la lista tiene exactamente un elemento, una condición de carrera con un timing específico; aparecen naturalmente cuando se prueban miles de combinaciones.
Hoy existen implementaciones en la mayoría de lenguajes: Hypothesis (Python), JSVerify (JavaScript), QuickCheck (Rust), junit-quickcheck (Java), Rantly (Ruby), entre otros.
Ejemplo: Test de invariante de concurrencia
Supongamos un sistema de asignación de folios únicos de documentos donde la invariante es no pueden existir dos documentos con el mismo folio para un mismo tipo de documento. Un test de concurrencia podría verificar esta invariante lanzando múltiples hilos simultáneos:
# Invariante: todos los folios asignados son únicos
folios = resultados_de_20_hilos_simultaneos
duplicados = folios.group_by { |f| f }.select { |_, v| v.size > 1 }
assert duplicados.empty?, "Se asignaron folios duplicados"
No importa cuántos hilos corran, en qué orden terminen, ni cuántas veces se ejecute el test. Si la invariante se rompe alguna vez, hay un bug en el sistema.
Más ejemplos de invariantes en testing
Integridad de datos en bases de datos, ej. transferencia entre cuentas Después de cualquier operación de transferencia entre cuentas, la suma total de saldos en el sistema debe ser la misma que antes de la transferencia. Este test puede ejecutarse con montos aleatorios, entre cuentas aleatorias, con concurrencia: la invariante debe sostenerse siempre.
Invariantes de serialización, ej. codificar un objeto a json: Si se serializa un objeto a JSON y se deserializa de vuelta, el resultado debe ser idéntico al original. Esta propiedad se puede verificar generando miles de objetos aleatorios con campos de todos los tipos y verificando su igualdad semántica, que los datos contenidos sean los mismos antes y después de serilizar/deserializar. Bugs como la pérdida de precisión en floats, el manejo incorrecto de zonas horarias en fechas, o la codificación errónea de caracteres Unicode aparecen rápidamente.
Consistencia en sistemas distribuidos: En un sistema con réplicas, después de propagar una escritura y esperar el tiempo de convergencia, todas las réplicas deben devolver el mismo valor para la misma clave. El test genera secuencias aleatorias de escrituras y lecturas, con delays variables, y verifica que la invariante de consistencia eventual se mantenga.
De la verificación a la especificación
Los tests basados en propiedades no solo verifican invariantes, sino que también las documentan. Cuando lees assert todos_los_folios_son_unicos, estás leyendo una especificación del sistema. Esto conecta directamente con el siguiente nivel: las invariantes como herramienta de diseño. Lo que en testing es una aserción, en diseño es un contrato.
3. Invariantes en Diseño de Software
¿Qué es una invariante de clase?
En diseño orientado a objetos, una invariante de clase es una restricción que toda instancia de la clase debe satisfacer en todo momento observable; después de la construcción, antes de la destrucción, y antes y después de cada método público. Los métodos pueden romper temporalmente la invariante durante su ejecución, pero deben restaurarla antes de retornar.
Design by Contract
El concepto fue formalizado por Bertrand Meyer en 1988 y se conoce como Design by Contract (DbC). Un contrato de software define tres tipos de aserciones:
- Precondiciones: Condiciones que el cliente debe cumplir antes de llamar a un método. Si no se cumplen, el bug está en el cliente.
- Postcondiciones: Condiciones que el método garantiza al terminar. Si no se cumplen, el bug está en el método.
- Invariantes de clase: Condiciones que deben ser verdaderas siempre que el objeto sea accesible. Actúan como una cláusula general que se aplica a todos los contratos de la clase.
Ejemplo: Invariantes en un sistema de folios
Considere un sistema de facturación electrónica con las siguientes invariantes de negocio:
- Un folio asignado nunca se reasigna invariante de unicidad
- Un folio emitido es siempre mayor al anterior invariante de monotonía
- Un folio siempre pertenece a un rango autorizado y vigente, emitido por la autoridad tributaria invariante de rango
Cada una se implementa con un mecanismo diferente:
- La unicidad con una
PRIMARY KEYen la base de datos. - La monotonía con
GREATEST(candidato, MAX(folio_anterior) + 1)en la consulta. - La validez de rango con un
EXISTScontra los rangos autorizados.
Juntas, estas invariantes definen qué significa que el sistema esté en un estado correcto.
El problema de la monotonía atómica
El ejemplo de monotonía (GREATEST(candidato, MAX(folio_anterior) + 1)) toca tangencialmente un problema difícil: ¿cómo garantizar esta invariante cuando múltiples procesos solicitan folios simultáneamente sin generar bloqueos masivos?
Una alternativa elegante es usar objetos nativos del motor de base de datos diseñados para este propósito. En PostgreSQL y Oracle, un SEQUENCE es un generador de números que garantiza monotonía y unicidad de forma atómica a nivel del motor, sin bloqueos sobre tablas de datos:
CREATE SEQUENCE folio_seq START WITH 1 INCREMENT BY 1;
-- Cada llamada a NEXTVAL retorna un número único y creciente,
-- incluso con cientos de conexiones simultáneas.
SELECT NEXTVAL('folio_seq');
El SEQUENCE abstrae la invariante de monotonía y la garantiza a nivel de motor, liberando a la capa de aplicación de esa responsabilidad. En bases de datos que no soportan SEQUENCE (como versiones antiguas de MySQL/MariaDB), la alternativa es usar una tabla auxiliar con UPDATE atómico o FOR UPDATE para serializar el acceso, lo cual funciona pero introduce contención bajo alta concurrencia.
La elección entre implementar la invariante en la aplicación, en una constraint de la base de datos, o en un objeto nativo del motor es una decisión de diseño que depende del volumen de concurrencia y las capacidades del motor.
Más ejemplos: invariantes en diferentes contextos
Bases de datos: Una cuenta bancaria tiene la invariante de que su saldo nunca puede ser negativo (o al menos, nunca inferior a su línea de crédito). Esta invariante se puede implementar con un CHECK constraint a nivel de tabla (CHECK (saldo >= -linea_credito)), o con lógica en la capa de aplicación. En un sistema contable, la invariante fundamental es que la suma de débitos debe ser igual a la suma de créditos. Si se rompe, el sistema está corrupto.
Interfaces de usuario: Un formulario de pago tiene la invariante de que el botón Confirmar solo está habilitado cuando todos los campos obligatorios están completos y válidos. Si el usuario puede presionar Confirmar con datos incompletos, la invariante se rompió. En una aplicación con navegación por pestañas, una invariante podría ser que exactamente una pestaña está activa en todo momento, ni cero ni más de una.
Sistemas distribuidos: En un sistema de caché distribuido, una invariante común es la de coherencia eventual. Si no se realizan nuevas escrituras, eventualmente todas las réplicas deben devolver el mismo valor. Otra invariante clásica es la de orden causal; si el evento A causó el evento B, entonces todo observador que vea B también debe haber visto A. La violación de estas invariantes produce bugs extremadamente difíciles de diagnosticar porque solo aparecen bajo condiciones específicas de latencia y concurrencia.
Estructuras de datos: Un árbol binario de búsqueda tiene la invariante de que para todo nodo, los valores del subárbol izquierdo son menores y los del subárbol derecho son mayores. Un heap tiene la invariante de que el valor de cada nodo es menor (o mayor) que el de sus hijos. Si una operación rompe la invariante, la estructura deja de funcionar correctamente: las búsquedas no encuentran elementos que existen, o las prioridades se desordenan.
Domain-Driven Design (DDD) e invariantes
En DDD, las invariantes ocupan una posición central. Eric Evans, en su libro Domain-Driven Design (2003), recomienda que los objetos del dominio nunca estén en un estado inválido: las fábricas deben crear instancias válidas, y cada operación debe preservar las invariantes. Evans introduce el concepto de agregado, un grupo de objetos relacionados que se tratan como una unidad para efectos de cambios de datos, con un objeto raíz que es responsable de mantener las invariantes del grupo completo. Las invariantes del agregado son las que determinan sus límites.
Lenguajes con soporte nativo
Algunos lenguajes soportan invariantes de forma nativa: Eiffel (el lenguaje diseñado por Meyer, construido alrededor de DbC), Ada (con Type_Invariant), D (con bloques invariant), y Dafny (con verificación formal que demuestra matemáticamente que las invariantes se cumplen antes de compilar).
En la práctica, estos lenguajes son minoritarios en el desarrollo comercial moderno. Eiffel es usado principalmente en contextos académicos y en sistemas críticos (aeronáutica, finanzas). Ada tiene una presencia fuerte en sistemas embebidos y defensa. Dafny es una herramienta de investigación de Microsoft. La mayoría de los desarrolladores trabajan en lenguajes sin soporte nativo para contratos — Java, Python, Ruby, JavaScript — y recurren a soluciones externas: JML (Java Modeling Language), Contracts for Java (de Google), icontract (Python), o simplemente asserts manuales. Algunos frameworks como Spring (Java) y Rails (Ruby) implementan validaciones declarativas que cumplen un rol similar al de las invariantes de clase, aunque sin la formalidad de DbC.
El hecho de que la mayoría de los lenguajes populares no soporten contratos nativamente no significa que las invariantes no importen. Al contrario, hace más importante que el programador las identifique y las implemente conscientemente, ya sea con asserts, tests, constraints de base de datos, o el sistema de tipos.
Invariantes y principios de diseño
Las invariantes no son un concepto aislado; se conectan naturalmente con otras prácticas de ingeniería de software.
Principios SOLID: El principio de sustitución de Liskov (LSP) establece que las subclases deben poder sustituir a sus clases base sin romper el programa. En términos de contratos, esto significa que una subclase puede debilitar precondiciones y fortalecer postcondiciones e invariantes, pero nunca al revés. Si una subclase rompe una invariante de su clase padre, viola LSP. El principio de responsabilidad única (SRP) también se relaciona, una clase con una sola responsabilidad tiene invariantes más simples y fáciles de mantener.
Refactorización: Cuando refactoriza código, las invariantes son su red de seguridad. Si conoce las invariantes del sistema, puede cambiar la implementación interna con confianza mientras las invariantes se mantengan. Martin Fowler, en Refactoring (1999), argumenta que los tests deben capturar el comportamiento observable del sistema, y ese comportamiento observable son, en esencia, las invariantes.
Patrones de diseño: Muchos patrones existen específicamente para proteger invariantes. El patrón Builder garantiza que un objeto complejo se construya en un estado válido (invariante de construcción). El patrón Strategy permite cambiar el comportamiento sin romper la invariante de la interfaz. El patrón State asegura que las transiciones de estado solo ocurran por caminos válidos, manteniendo la invariante subyacente.
Conectando los dominios. Compilador, Testing, Diseño.
Los tres usos del término comparten la misma esencia, aplicada en niveles de abstracción diferentes:
El compilador detecta que un valor es invariante al loop, lo mueve fuera para optimizar.
El test verifica que una propiedad del sistema es invariante, la afirma para detectar bugs.
El diseño define que una regla de negocio debe ser invariante, la implementa con constraints para garantizar correctitud.
En los tres casos, identificar la invariante es el paso intelectual más importante. Una vez identificada, la implementación (mover código, escribir un assert, agregar un constraint) es la consecuencia natural.
Reflexión. Invariantes y Calidad del Software
Identificar invariantes no es solo un ejercicio académico. Es una de las herramientas más poderosas para construir software que funcione correctamente a lo largo del tiempo. Un CHECK (saldo >= 0) en la base de datos comunica una regla de negocio de forma más confiable que cualquier documento de especificación, porque se ejecuta en cada operación. Un assert todos_los_folios_son_unicos se ejecuta en cada build y alerta inmediatamente si alguien introduce un cambio que rompe la invariante. En proyectos donde los desarrolladores rotan frecuentemente, estas invariantes explícitas son radicalmente más útiles que el conocimiento tribal que se pierde cuando alguien deja el equipo.
Las invariantes como guía de refactorización
Cuando necesita cambiar código que no escribió, la primera pregunta debería ser: ¿Cuáles son las invariantes de este sistema? Si son conocidas, puede cambiar la implementación con confianza. Si no las conoce, cualquier cambio es un riesgo. Los mejores sistemas son aquellos donde las invariantes son obvias, ya sea por la estructura del código, los tests que las verifican, o los constraints que las imponen.
Las invariantes como detector de problemas de diseño
Si una invariante es difícil de mantener, eso suele indicar un problema de diseño. Si necesita verificar la misma condición en diez lugares diferentes del código, probablemente la invariante debería estar encapsulada en un solo lugar: una clase, un módulo, un constraint de base de datos. La dificultad de mantener una invariante es una señal de que las responsabilidades no están bien distribuidas.
Las invariantes a lo largo del ciclo de vida
Las invariantes no son estáticas. Evolucionan con el sistema: al agregar una nueva funcionalidad, es posible que necesite agregar nuevas invariantes o relajar las existentes. Lo importante es que cada cambio sea consciente y deliberado. Los bugs más peligrosos no son los que rompen una invariante (esos se detectan rápido), sino los que cambian silenciosamente una invariante sin que nadie lo note.
La práctica más valiosa que un equipo de desarrollo puede adoptar es preguntarse, ante cada nueva clase, cada nueva tabla, cada nueva API: ¿cuáles son las invariantes aquí, y cómo nos aseguramos de que se cumplan?
Apendice
Optimizacion en lenguajes interpretados
Ruby
Ruby 2.0 (MRI/CRuby sin JIT): No realiza ninguna optimización de este tipo. El intérprete ejecuta bytecode (instrucciones intermedias) una por una, evaluando cada condición en cada iteración del loop sin importar si el valor cambió o no. La responsabilidad de sacar código invariante del loop recae completamente en el programador.
Ruby 2.6+ (MJIT): Ruby 2.6 introdujo MJIT, el primer compilador JIT de CRuby. Funciona compilando métodos que se ejecutan muchas veces a código C y luego a código máquina vía GCC o Clang. Aunque esto permite que el compilador C subyacente aplique algunas optimizaciones, MJIT fue diseñado principalmente para eliminar el costo de interpretar bytecode, no para aplicar optimizaciones agresivas como LICM. En la práctica, MJIT brillaba en benchmarks sintéticos, pero en aplicaciones Rails reales la ganancia era mínima, porque Rails tiene miles de métodos pequeños donde casi ninguno llegaba a entrar en calor lo suficiente como para que el JIT decida compilarlo.
Ruby 3.1+ (YJIT): YJIT, desarrollado por Shopify y ahora parte oficial de CRuby, representa un salto significativo. En vez de intentar optimizaciones clásicas como LICM, YJIT se enfocó en reducir el costo del despacho dinámico de métodos (el proceso que Ruby realiza cada vez que invoca un método para determinar cuál ejecutar), que es donde Ruby gasta la mayor parte del tiempo. YJIT usa una técnica llamada Basic Block Versioning, generando código máquina especializado para los tipos que observa en runtime, y elimina verificaciones redundantes. En Ruby 3.3, YJIT puede hacer que aplicaciones Rails sean mucho más rápidas que el intérprete base.
JavaScript
V8/TurboFan (Chrome, Node.js): Es el caso más avanzado entre los lenguajes dinámicos. El motor V8 usa un pipeline de múltiples etapas: primero, Ignition (un intérprete de bytecode) ejecuta el código y recopila información sobre qué tipos de datos se usan y qué funciones se llaman con frecuencia. Luego, TurboFan (el compilador optimizador) genera código máquina altamente optimizado para las funciones "calientes". TurboFan sí aplica LICM explícitamente: por ejemplo, mueve arr.length fuera de un loop for cuando detecta que el array no cambia. También realiza loop unrolling (desenrollar loops para reducir saltos), eliminación de código muerto, y escape analysis (determinar si un objeto puede vivir solo en la pila en vez del heap, evitando el costo de crearlo y destruirlo con el recolector de basura). La diferencia clave con lenguajes compilados es que TurboFan puede deoptimizar: si una suposición de tipo resulta incorrecta en runtime, descarta el código optimizado y vuelve al intérprete. Es un proceso costoso pero garantiza que el programa siempre funcione correctamente.
Python
CPython (el intérprete estándar): Hasta la versión 3.12, CPython no tiene JIT. Python 3.11 introdujo un Specializing Adaptive Interpreter que optimiza bytecodes frecuentes (por ejemplo, si detecta que una suma siempre recibe enteros, usa una versión especializada más rápida), y Python 3.13 agregó un JIT experimental basado en copy-and-patch (una técnica que une plantillas de código máquina precompiladas, documentada en PEP 744). Sin embargo, sus mejoras son modestas y no incluyen optimizaciones como LICM.
PyPy: PyPy es un intérprete alternativo de Python con un JIT de tracing (en vez de compilar funciones completas, detecta secuencias de instrucciones que se ejecutan repetidamente y las compila). PyPy puede ser hasta 4.7x más rápido que CPython en promedio, y hasta 50x más rápido en casos específicos con loops calientes. El beneficio es mayor en programas de ejecución larga; scripts cortos no se benefician porque el JIT necesita tiempo para "calentar" (recopilar suficiente información antes de optimizar).
Java
HotSpot JVM: La JVM de Oracle/OpenJDK es el estándar de referencia en compilación JIT. Su compilador C2 aplica LICM, loop unrolling, escape analysis, vectorización (convertir operaciones escalares en operaciones que procesan múltiples datos simultáneamente usando instrucciones SIMD del procesador) y muchas otras optimizaciones de forma rutinaria. La JVM fue pionera en demostrar que un lenguaje con tipado dinámico parcial podía alcanzar rendimiento cercano a C en muchos escenarios.
Referencias
Origen del Concepto de Invariante
Klein, F. — Vergleichende Betrachtungen über neuere geometrische Forschungen (Programa de Erlangen). Deichert, Erlangen, 1872.
El documento que propuso clasificar geometrías mediante sus invariantes bajo grupos de transformaciones.Floyd, R. W. — Assigning Meanings to Programs. Proceedings of Symposia in Applied Mathematics, 1967.
Primer uso formal de invariantes para razonar sobre corrección de programas.Hoare, C. A. R. — An Axiomatic Basis for Computer Programming. Communications of the ACM, 1969.
Introduce la triple de Hoare y formaliza el concepto de loop invariant.Wikipedia — Erlangen program
MacTutor History of Mathematics — Felix Klein
Wikipedia — Cross-ratio
Introducción accesible al concepto de razón cruzada y su papel en geometría proyectiva.
Compiladores (Loop-Invariant Code Motion y Loop Unswitching)
Aho, A. V., Lam, M. S., Sethi, R., & Ullman, J. D. — Compilers: Principles, Techniques, and Tools (2nd ed.). Addison-Wesley, 2006.
El libro del dragón. Referencia canónica sobre optimizaciones de loops.Muchnick, S. S. — Advanced Compiler Design and Implementation. Morgan Kaufmann, 1997.
Tratamiento en profundidad de LICM y otras optimizaciones.Appel, A. W. — Modern Compiler Implementation in ML. Cambridge University Press, 1998.
Capítulos 18.1–18.3 cubren LICM como caso de eliminación de redundancia parcial.Wikipedia — Loop-invariant code motion
Wikipedia — Loop unswitching
Platzer, A. — Lecture Notes on Loop-Invariant Code Motion (CMU 15-411)
Pekhimenko, G. — LICM: Loop Invariant Code Motion (University of Toronto CSC D70)
Cornell Virtual Workshop — Loop-Invariant Code Motion
Monniaux, D. & Becker, H. — Formally Verified Loop-Invariant Code Motion and Assorted Optimizations. ACM TECS, 2022.
https://dl.acm.org/doi/10.1145/3529507
Lenguajes Interpretados, VMs y JIT Compilation
Shopify Engineering — YJIT: Building a New JIT Compiler for CRuby
Shopify Engineering — Ruby 3.2's YJIT is Production-Ready
Rails at Scale — Ruby 3.3's YJIT: Faster While Using Less Memory
Chevalier-Boisvert, M. et al. — Evaluating YJIT's Performance in a Production Context. MPLR 2023.
https://dl.acm.org/doi/10.1145/3617651.3622982Heroku Blog — Ruby 2.6 Released: Just-In-Time Compilation Is Here
Big Binary — MJIT Support in Ruby 2.6
V8 Blog — Digging into the TurboFan JIT
V8 Docs — TurboFan
Jindal, R. — Understanding Just-In-Time (JIT) Compilation in V8: A Deep Dive
Baloney, A. — Python 3.13 gets a JIT
Python Enhancement Proposals — PEP 744: JIT Compilation
PyPy — Performance Tips
InfoWorld — CPython vs. PyPy: Which Python runtime has the better JIT?
Zhang et al. — Python meets JIT compilers: A simple implementation and a comparative evaluation. Software: Practice and Experience, 2024.
https://onlinelibrary.wiley.com/doi/10.1002/spe.3267
Testing (Invariantes como Propiedades)
Hébert, F. — Property-Based Testing with PropEr, Erlang, and Elixir. The Pragmatic Bookshelf, 2019.
El libro más práctico y accesible sobre property-based testing.Van den Ende, W. — The (Little) QuickCheck Workbook. Leanpub.
https://leanpub.com/quickcheckworkbookO'Sullivan, B., Stewart, D., & Goerzen, J. — Real World Haskell, Chapter 11: Testing and Quality Assurance.
https://book.realworldhaskell.org/read/testing-and-quality-assurance.htmlCockx, J. — An Introduction to Property-Based Testing with QuickCheck
Typeable — Property-Based Testing With QuickCheck
Kan, Y. — Property-Based Testing: Generative Testing for System Invariants
University of Pennsylvania (CIS 1940) — Property-based testing
Diseño de Software (Invariantes de Clase y Design by Contract)
Meyer, B. — Object-Oriented Software Construction (2nd ed.). Prentice Hall, 1997. Primera edición 1988.
El libro que formalizó Design by Contract. Referencia fundamental.Evans, E. — Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley, 2003.
Capítulos sobre entidades, agregados e invariantes del dominio.Reddy, M. — API Design for C++. Morgan Kaufmann, 2011.
Sección sobre Design by Contract aplicado a diseño de APIs.Eiffel Software — Design by Contract: Introduction
Wikipedia — Design by contract
Wikipedia — Class invariant
Microsoft Learn — Invariants and Inheritance in Code Contracts (MSDN Magazine)
Fowler, M. — Design by Contract
Object Computing — Design by Contract in Java with Google
UCSB (CS 272) — Design by Contract and JML
Refactorización, Patrones de Diseño y Principios SOLID
Fowler, M. — Refactoring: Improving the Design of Existing Code (2nd ed.). Addison-Wesley, 2018.
Argumenta que los tests deben capturar el comportamiento observable (invariantes) del sistema.Martin, R. C. — Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall, 2008.
Principios de diseño limpio que favorecen invariantes simples y encapsuladas.Martin, R. C. — Agile Software Development: Principles, Patterns, and Practices. Prentice Hall, 2002.
Tratamiento detallado de los principios SOLID, incluyendo el principio de sustitución de Liskov y su relación con invariantes.Gamma, E., Helm, R., Johnson, R., & Vlissides, J. — Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1994.
"El libro de la banda de cuatro". Muchos patrones existen para proteger invariantes.Liskov, B. & Wing, J. — A Behavioral Notion of Subtyping. ACM Transactions on Programming Languages and Systems, 1994.
Formalización del principio de sustitución en términos de precondiciones, postcondiciones e invariantes.
Top comments (0)