Artículo técnico

Range check error en Delphi: causas y soluciones

Un programa Delphi se detiene con ERangeError y el mensaje seco «Range check error», a menudo solo en una de sus dos configuraciones de build. Delphi lanza esta excepción cuando código compilado con la comprobación de rangos activa ({$R+}) indexa un array o una string fuera de sus límites, o guarda un valor que no cabe en el tipo ordinal de destino. La corrección casi siempre pertenece al lugar donde se calculó el índice o el valor malo, que rara vez es la línea donde se para el debugger

¿Qué significa «Range check error» en Delphi?

ERangeError viene de las comprobaciones que el compilador inserta cuando range checking está activo. Con {$R+} (forma larga {$RANGECHECKS ON}, o Project Options › Building › Delphi Compiler › Compiling › Range checking), cada subíndice de array y de string se compara con los límites declarados, y cada asignación a un ordinal más pequeño, un subrange o una variable enumerada se compara con el rango de ese tipo. El estado por defecto es {$R-}, y el código compilado así no contiene comprobación alguna, así que una escritura fuera de límites aterriza simplemente en la memoria de al lado. Una comprobación fallida lanza ERangeError con el resource string «Range check error» cuando SysUtils está enlazado; un programa que nunca usa SysUtils termina con el runtime error 201. El error de compilación E1012 (Constant expression violates subrange bounds) solo cubre índices que el compilador ve como constantes; todo lo que se calcula en tiempo de ejecución depende de {$R+}. El interruptor también funciona por unidad: los DCU precompilados conservan el estado con el que se construyeron, y un {$R-} al principio de una unidad se impone sobre la opción del proyecto para esa unidad

La comprobación de desbordamiento es otro interruptor con otra excepción. {$Q+} ({$OVERFLOWCHECKS ON}) comprueba la aritmética entera como +, -, *, Inc y Dec y lanza EIntOverflow cuando un resultado no cabe; {$R+} comprueba índices y almacenamientos. Las dos se dan la mano en los tipos pequeños. La aritmética de Byte se evalúa como Integer, así que B := B + 1 con B = 255 produce 256 sin desbordamiento alguno, y es el almacenamiento de vuelta en el Byte lo que falla el range check. Inc(B) sobre el mismo valor es en cambio una operación con comprobación de desbordamiento: con {$Q+} lanza EIntOverflow, y con solo {$R+} da la vuelta silenciosamente a 0. Qué configuraciones tienen estos interruptores activos depende de la versión de Delphi y de cómo se creó el proyecto. Lea los valores reales en Project Options para cada configuración de build antes de fiarse de una teoría sobre por qué el error aparece solo en un build

Las causas habituales, con repros mínimos

La mayoría de los range check errors en código Delphi real caen en un puñado de patrones, cada uno con un arreglo pequeño y mecánico. Los arrays son el caso más común. Un array[0..9] estático acepta índices 0 a 9, un array dinámico o TBytes acepta 0..Length(A) - 1, y las strings son 1-based, así que escriba los bucles contra Low y High en lugar de límites fijados a mano. El caso vacío es donde el código se rompe. High de un array dinámico vacío es -1, así que un bucle for I := 0 to High(A) con contador Integer correctamente ejecuta cero veces, mientras que A[High(A)] o A[Count - 1] usados para traer «el último elemento» leen el índice -1. Otros casos relacionados se comportan distinto:

  • Las clases contenedoras comprueban sus propios índices, esté como esté {$R}: TList y TStringList lanzan EListError o EStringListError, y TList<T> lanza EArgumentOutOfRangeException
  • Los conteos que llegan como Cardinal o DWORD desde una cabecera de fichero o una API son arriesgados en cero, porque lo que vale Count - 1 depende de cómo tipifique el compilador la expresión; mantenga los contadores de bucle como Integer, valide el conteo una vez, conviértalo y pruebe Count > 0 explícitamente
procedure ArrayRepros(const Data: TBytes; Buf: Pointer);
var
  Last: Byte;
  P: PByte;
  I: Integer;
begin
  // Array vacío: High(Data) = -1, así que esto lee Data[-1]
  Last := Data[High(Data)];               // ERangeError con {$R+}

  // Arreglo: pruebe la longitud antes de pedir el último elemento
  if Length(Data) > 0 then
    Last := Data[High(Data)];

  // TByteArray se declara como array[0..32767] of Byte, así que con {$R+}
  // cualquier índice por encima de 32767 falla aunque Buf guarde megas
  I := 40000;
  Last := PByteArray(Buf)^[I];            // ERangeError con I = 40000

  // Arreglo: indexe el buffer real, o use aritmética de PByte
  P := PByte(Buf);
  Inc(P, I);
  Last := P^;                             // sin range check: valide I usted mismo
end;

El caso de PByteArray caza incluso a desarrolladores con oficio, porque el límite declarado no tiene nada que ver con el buffer. Tanto el SysUtils de Delphi como el de Free Pascal declaran TByteArray como array[0..32767] of Byte, una ventana de 32 KB que viene del código de 16 bits. Con {$R+} la comprobación compara su índice con 32767, así que un offset válido dentro de un stream de 100 KB lanza ERangeError; con {$R-} no hay comprobación, y un índice más allá del final real del buffer nunca se detecta. Indexar el TBytes en sí le da una comprobación contra la longitud real, lo que lo hace el valor por defecto mejor. La aritmética de PByte no se comprueba en absoluto. Acompáñela de una prueba explícita contra el tamaño del buffer

Los almacenamientos ordinales son la otra gran familia. Asignar 256 a un Byte, 70000 a un Word, o 13 a un subrange 1..12 falla la comprobación, y también asignar un Integer negativo a un Cardinal. Delphi evalúa operandos mixtos con y sin signo de 32 bits como Int64, así que la aritmética en sí suele ser correcta y el error aparece en el almacenamiento, lo que le señala la variable cuyo tipo es demasiado estrecho. Los typecasts duros son el agujero de esta red: Byte(I), Cardinal(I) y TMyEnum(I) no generan range check y truncan o reinterpretan en silencio. Cuando un valor de 32 bits leído de un fichero escoge un miembro de un enum, compárelo como Cardinal contra Ord(High(TMyEnum)) antes del cast

var
  B: Byte;
  C: Cardinal;
  I: Integer;
  Addr: Integer;
  Obj: TObject;
begin
  B := 255;
  B := B + 1;            // evaluado como Integer 256, el almacenamiento falla: ERangeError

  I := -1;
  C := I;                // negativo en Cardinal: ERangeError
  C := Cardinal(I);      // hard cast: sin comprobación, C = 4294967295

  // Win64: los punteros son 8 bytes, Integer sigue en 4
  Obj := TObject.Create;
  Addr := NativeInt(Obj);  // Integer: falla por encima de 2 GB (Cardinal: de 4 GB)
  // Arreglo: guarde valores del tamaño de un puntero en NativeInt / NativeUInt
end;

// Aritmética módulo 2^32 que debe comportarse igual en Delphi y FPC
function AddMod32(A, B: Cardinal): Cardinal; inline;
begin
  Result := Cardinal((UInt64(A) + UInt64(B)) and $FFFFFFFF);
end;

Win64 añade una variante del mismo error por el tamaño del puntero. Pointer, NativeInt, TComponent.Tag (un NativeInt desde XE2), LPARAM y WPARAM son todos de 8 bytes en Win64, mientras que Integer, Longint y Cardinal se quedan en 4. Guardar una dirección mediante una asignación con comprobación de rango lanza ERangeError en cuanto la dirección sale del rango del tipo de destino: por encima de 2 GB para Integer, de 4 GB para Cardinal. Guardarla mediante un hard cast la trunca y produce un access violation más adelante. Los programas de prueba pequeños suelen asignar memoria baja en el espacio de direcciones, así que esta clase de bug tiende a aparecer en procesos de larga vida más que en tests unitarios. Declare como NativeInt o NativeUInt todo lo que guarde un puntero, un handle o un parámetro de mensaje. Los peligros de Longint y punteros en Linux64 siguen la misma lógica para objetivos LP64, donde el propio Longint cambia de tamaño

¿Por qué el error aparece solo en Debug, o solo bajo FPC?

Un range check error que aparece en un solo build es casi siempre un acceso real fuera de límites que el otro build no está comprobando. Los builds Release con {$R-} ejecutan el mismo índice defectuoso sin queja y o bien leen la memoria del vecino o la corrompen, lo cual es peor que la excepción. Free Pascal añade sus propios interruptores. {$mode delphi} cambia sintaxis y semántica del lenguaje pero no activa comprobación alguna; el range checking bajo FPC viene de {$R+} o de la opción de línea de comandos -Cr, y el overflow checking de -Co, que Lazarus muestra en Project Options › Compiler Options › Debugging. FPC también evalúa expresiones enteras en el tamaño de entero nativo o mayor, así que una expresión que en Delphi de 32 bits da la vuelta en silencio puede producir en un objetivo FPC de 64 bits un valor por encima de High(Cardinal), y almacenarlo de vuelta en el Cardinal falla la comprobación. Nosotros dimos con esto exacto en nuestro propio código SHA-256: las sumas módulo 232 escritas como sumas Cardinal a secas lanzaban Range check error en un build FPC con comprobaciones. Sumar en UInt64 y enmascarar, como hace AddMod32 más arriba, mantiene la salida idéntica byte a byte en ambos compiladores sin apagar las comprobaciones para toda la unidad. Más trampas de portabilidad de este tipo están recogidas en código Delphi que solo funciona por accidente bajo FPC

Hacer que el debugger se pare en la línea que lo lanzó

La forma más rápida de encontrar un range check error es dejar que el debugger del IDE rompa en él y subir por la pila de llamadas hasta el código que produjo el valor malo. La excepción marca dónde disparó la comprobación. La causa suele estar unos marcos más abajo:

  1. Ejecute la configuración Debug con range checking activo, y asegúrese de que ERangeError no está en la lista de ignorados de la página Language Exceptions de las opciones del debugger
  2. Cuando el diálogo reporte «raised exception class ERangeError with message 'Range check error'», escoja Break y abra la ventana Call Stack; los marcos por debajo del primero muestran de dónde salió el índice o el valor
  3. Si los marcos superiores están dentro de la RTL o la VCL, active «Use debug .dcus» para que esos marcos resuelvan a fuente
  4. Ponga un breakpoint condicional en la asignación que calcula el índice (por ejemplo Index < 0) para cazar el error un paso antes de lo que lo caza la excepción
  5. Para errores que ocurren solo en la máquina de un cliente, distribuya el build con range checking activo y un logger de stack traces como madExcept, EurekaLog o JCL Debug, que escribe la misma pila de llamadas en un fichero de log

¿Conviene apagar el range checking?

Mantenga el range checking activo en cada build Debug y de CI, y apáguelo solo localmente, alrededor de un bucle caliente medido cuyos límites valide usted mismo. Apagarlo globalmente no elimina el bug; elimina el reporte. Un ejemplo clásico es una rutina de formateo hexadecimal que llama a SetLength(Result, 16) y escribe los caracteres 17 a 24: con {$R+} falla inmediatamente, con {$R-} escribe ocho caracteres más allá del final de la string en cada llamada, en la memoria del heap que venga detrás, y crashea en algún lugar sin relación mucho más tarde. Cuando una rutina de verdad es demasiado caliente para comprobaciones, acote el cambio correctamente. {$R-} seguido de {$R+} parece ordenado pero fuerza la comprobación para el resto de la unidad aunque el proyecto la tuviera apagada, así que guarde y restaure el estado previo, y deje la región sin comprobar como una rutina pequeña entera cuyos llamadores hagan cumplir los límites

{$IFOPT R+}
  {$DEFINE RANGECHECKS_WERE_ON}
  {$R-}
{$ENDIF}
procedure XorBlock(Dst, Src: PByte; Count: Integer);
var
  I: Integer;
begin
  // Los llamadores garantizan que Count cabe en ambos buffers; comprobado una vez fuera del bucle
  for I := 0 to Count - 1 do
    Dst[I] := Dst[I] xor Src[I];
end;
{$IFDEF RANGECHECKS_WERE_ON}
  {$UNDEF RANGECHECKS_WERE_ON}
  {$R+}
{$ENDIF}

Range check errors en código de parseo de PDF

El código que parsea ficheros PDF está especialmente expuesto, porque casi cada número que usa como índice viene del propio fichero. Los offsets de bytes de la tabla de referencias cruzadas, los valores /Length de los streams, los anchos de campo del array /W de un cross-reference stream (ISO 32000-1 §7.5.8), los conteos de objetos y las entradas /ByteRange de las firmas son todos datos no fiables en un fichero malformado u hostil. Un guard ingenuo como if A + B > Length(Data) falla ante un /ByteRange [9223372036854775807 1 0 0] diseñado a mano: con el overflow checking apagado, A + B da la vuelta a un Int64 negativo, la prueba pasa, y Data[A] falla más tarde. Pruebe cada valor contra la longitud restante ((A >= 0) and (B >= 0) and (A <= Len) and (B <= Len - A)) para que ninguna suma pueda dar la vuelta. Los guards compuestos esconden otra trampa. Delphi evalúa and en cortocircuito de izquierda a derecha, pero una prueba de cota superior sola no rechaza un índice negativo, porque -1 < Length(FPages) es True; la cota inferior con signo tiene que probarse antes que la superior

// Parece seguro, pero un PageIndex negativo pasa la prueba de cota superior:
//   if FDocOpen and (PageIndex < Length(FPages)) and
//      (FPages[PageIndex].PageObj <> nil) then ...

if (PageIndex >= 0) and (PageIndex < Length(FPages)) then
begin
  if FDocOpen and (FPages[PageIndex].PageObj <> nil) then
    Result := FPages[PageIndex].PageObj
  else
    Result := nil;
end
else
  raise ERangeError.CreateFmt(
    'Page index %d is out of range (0..%d)',
    [PageIndex, Length(FPages) - 1]);
Diagrama de flujo de un range check error en Delphi donde un DestIndex negativo burla una comprobación de límites de un solo lado y lanza ERangeError en el acceso al array, comparado con el orden de guard corregido que cortocircuita con seguridad
Con $R+ cada subíndice se comprueba en tiempo de ejecución, así que un índice negativo sobrevive a una prueba de cota superior desnuda y dispara en el acceso al array propio. Probar antes la cota inferior con signo deja que la evaluación en cortocircuito la rechace antes de tocar elemento alguno

El guard detiene el crash pero no explica por qué el índice estaba mal, y en código de manejo de páginas la razón habitual es un desajuste entre dos ordenaciones. ISO 32000-1 §7.7.3 define el orden de páginas a través de los arrays Kids del árbol de páginas; los números de objeto y los offsets de bytes no dicen nada sobre él, y el objeto 20 puede contener la página 1 mientras el objeto 3 contiene la página 2. El código que construye su lista de páginas escaneando objetos en orden de fichero funciona en todo documento cuyo generador escribió las páginas en secuencia, y se equivoca en cualquiera que no lo hiciera, como un fichero editado con una portada añadida después. Los índices que los llamadores calculan en orden de visualización caen en la página equivocada o más allá del final de la lista. El estudio de caso del orden de páginas PDF muestra cómo confirmar el desajuste con qpdf y reconstruir la lista de páginas desde el árbol de páginas

Diagrama Delphi que contrasta el orden de almacenamiento de objetos PDF descubierto por un escaneo de referencias cruzadas con el orden de visualización recuperado recorriendo el array Kids del árbol de páginas desde el catálogo
Los escaneos secuenciales numeran las páginas según el orden que el escritor escogió, mientras los visores siguen el array Kids del árbol de páginas. Construir el array de páginas desde el recorrido del catálogo mantiene los índices de los llamadores alineados y dentro de rango

Para sacudir estos errores de su propio parser, constrúyalo con {$R+} y {$Q+} y páselo por un corpus variado: ficheros de Word, LaTeX, firmware de escáneres, utilidades de división y fusión, y unos pocos truncados a propósito. Cada fichero que lanza una excepción es un punto de datos sobre un supuesto que su código hace, así que arregle el supuesto una vez en lugar de añadir un caso especial por generador que disparará en el momento equivocado con la siguiente fuente. Para código que llama a una biblioteca PDF, trate el conteo de páginas de la biblioteca como la autoridad. En el HotPDF Delphi PDF component, LoadFromFile devuelve el conteo de páginas del documento cargado y LoadedPageCount lo reporta después; los índices de página siguen el orden de visualización del árbol de páginas, así que 0..LoadedPageCount - 1 es el rango seguro para cualquier índice que derive de datos externos