A lo largo de este texto se trabaja únicamente con números con signo, esto es debido a que uno de mis requisitos personales es que mis librerías sea compatible con el acuerdo de independencia CLS descrito en el estándar ECMA-335 CLI.
Recientemente, me encontraba trabajando un proyecto personal desarrollado en C# que por diversas razones, y con el afán de experimentar un poco, necesitaba de un array que pudiera tomar como índice un entero long, es decir, con 64 bits de indexación.
El entorno de ejecución .NET solo soporta nativamente arrays con enteros int, 32 bits, como índices. Esto no solo se trata de una limitación de su librería estándar sino de su propio recolector de basura (GC); a menos que se active una configuración en concreto, los objetos se encuentran limitados a 2 GiB.
Elemento
<gcAllowVeryLargeObjects>En plataformas de 64 bits, habilita matrices que tienen un tamaño total de más de 2 gigabytes (GB).
<gcAllowVeryLargeObjects enabled="true|false" />— Microsoft Learn (fuente)
En todo caso, crear un array de tal tamaño tampoco sería ideal, pues supondría la aparición grandes monolíticos bloques de memoria manejada que serían problemáticos para el GC.
Arrays paginados
Decidí acudir a un recurso que solventaría los problemas de memoria y que, en mi cabeza, también me permitiría tener una indexación de 64 bits, los arrays paginados.
Un array paginado se trata de una estructura de datos en la que los elementos se almacenan en arrays de tamaño fijo denominados páginas. Mi teoría consistía en que con la suficiente cantidad de páginas podría simular una indexación de 64 bits.
Mi implementación consistía en una sparse list, una estructura de datos que permite el acceso a índices discontinuos introduciendo punteros nulos en aquellas páginas completamente vacías.
namespace Redacted.Collections;
internal class SparseList<T>(int pageSize, T tombstone = default!)
{
public T this[long index]
{
get => Get(index);
set => Set(index, value);
}
private readonly List<SparseListPage<T>?> _pages = [];
private T Get(long index)
{
var (pageIndex, elementIndex) = GetIndices(index);
if (pageIndex >= _pages.Count) return tombstone;
var page = _pages[pageIndex];
if (page == null) return tombstone;
return _pages[pageIndex]![elementIndex];
}
private void Set(long index, T value)
{
var (pageIndex, elementIndex) = GetIndices(index);
var isValueTombstone = Equals(value, tombstone);
var missingPages = (pageIndex + 1) - _pages.Count;
if (missingPages > 0 && isValueTombstone) return;
for (var i = 0; i < missingPages; i++)
{
_pages.Add(null);
}
_pages[pageIndex] ??= new SparseListPage<T>(pageSize, tombstone);
var page = _pages[pageIndex]!;
if (page.Count == 1 && isValueTombstone)
{
_pages[pageIndex] = null;
return;
}
page[elementIndex] = value;
}
private (int pageIndex, int elementIndex) GetIndices(long index)
{
return ((int)(index / pageSize), (int)(index % pageSize));
}
}⏎namespace Redacted.Collections;
internal class SparseListPage<T>
{
public int Size => _size;
public int Count;
public T this[int index]
{
get => _data[index];
set
{
var isNewValueTombstone = Equals(value, _tombstone);
var isStoredValueTombstone = Equals(_data[index], _tombstone);
if (!isNewValueTombstone && isStoredValueTombstone)
{
Count++;
}
else if (isNewValueTombstone && !isStoredValueTombstone)
{
Count--;
}
_data[index] = value;
}
}
private readonly int _size;
private readonly T _tombstone;
private readonly T[] _data;
public SparseListPage(int size, T tombstone = default!)
{
_size = size;
_tombstone = tombstone;
_data = new T[size];
Array.Fill(_data, _tombstone);
}
}Con mi implementación funcionando y respondiendo correctamente a mis tests unitarios me dispuse a llevar estas estructuras al mundo real. Le dedico unas horas a mi proyecto, ejecuto dotnet run en la terminal y...
Unhandled exception. System.ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')
at System.Collections.Generic.List`1.get_Item(Int32 index)
at Redacted.Collections.SparseSet.SparseList`1.Get(Int64 index) in [...]
at Redacted.Collections.SparseSet.SparseList`1.get_Item(Int64 index) in [...]En ese momento me sorprendió, resulta que al intentar usar el índice más alto posible, long.MaxValue, es decir, con el número 9.223.372.036.854.775.807, el método GetIndices() devuelve el valor -1 como índice de página.
Esto es debído a que la operación index / pageSize no garantiza que sea un valor menor o igual a int.maxValue, es decir, el número 2.147.483.648. Esto tiene sentido, no es difícil ver que toda operación long / int no garantiza esto.
Justificación
La razón, que rompe cualquier posibilidad de que mi teoría fuese cierta, es sencilla.
Un array con índices de 32 bits puede almacenar hasta $ 2^{31} $ elementos, esto implica que cada página puede almacenar dicha cantidad; y dado que los punteros hacia las páginas también se almacenan en un array, puede haber hasta $ 2^{31} $ páginas.
Es significa que puede haber hasta $ 2^{31} * 2^{31} = 2^{31 + 31} = 2^{62} $ elementos en total en un array paginado, pero un array con índices de 64 bits puede acumular hasta $ 2^{63} $ elementos. Hay $ 2^{63} - 2^{62} $ elementos inaccesibles, es decir, los últimos $ 4.611.686.018.427.387.904 $ índices del array no son indexables.
¿Y si quitáramos el signo?
Si nos encontrásemos con un lenguaje que permitiese índices de 32 bits sin signo, se podrían almacenar hasta $ 2^{32} $ elementos; con el mismo razonamiento de antes, podríamos tener hasta $ 2^{32} $ páginas.
Esto nos permitiría llegar hasta los $ 2^{32} * 2^{32} = 2^{32 + 32} = 2^{64} $ elementos en total al dar uso de un array paginado. Es interesante que en este lenguaje hipotético, un array paginado no solo podría almacenar todos los elementos de un array de índices con signo de 64 bits ($ 2^{63} $ elementos) si no también de un array de índices sin signo de 64 bits ($ 2^{64} $ elementos).
Demostración
Vistos los resultados anteriores es natural que surja interés de en hallar una demostración matemática:
Planteamos $ a \gt b$, donde $ a \in \mathbb{N^+} $ es el número de bits de los índices del array que queremos replicar, y $ b \in \mathbb{N^+} $ el número de bits de los índices de los arrays que conforman nuestro array paginado (la réplica).
Para que la réplica sea válida todos los elementos del array original deben poder ser introducidos en la réplica:
$$ 2^a = 2^b * 2^b \implies 2^a = 2^{b + b} \implies a = 2b \implies a / 2 = b$$
Y dado que nos encontramos trabajando con aritmética entera, la expresión $ a / 2 $ solo existe si $ a \bmod 2 = 0 $; dicho de otro modo, $ a $ debe ser un número par $ \blacksquare $.
Alternativas
Dudo que existan alternativas para cubrir este concreto caso, la mayoría de sistemas donde puede surgir una necesidad así son capaces usar índices de 64 bits. Este intento mio ha resultado ser poco más que un curioso experimento que quería compartir.