Categorías
C#

Safe indexers

El lenguaje de AUSTRA es un sencillo lenguaje de fórmulas, inspirado mayormente en la Programación Funcional. Esto lo hace fácil de usar, y sobre todo, lo hace bastante seguro: no nos deja estropear los datos de una serie que hemos obtenido, por ejemplo, desde una fuente de pago. Al mismo tiempo, nos obliga a ser «creativos» para resolver problemas que serían más sencillos en un lenguaje tradicional.

Por ejemplo, digamos que quiero crear un vector de 1024 elementos, con la serie de números cuadrados. Eso es sencillo, si usamos el constructor (o «método de clase») apropiado. En el lenguaje de AUSTRA, se hace así:

vector::new(1024, i => (i + 1)^2)

AUSTRA soporta constructores con nombres: a diferencia de C#, en los que todos los constructores se definen con el nombre de la clase, AUSTRA nos permite distinguir entre constructores por medio de sus nombres. Por ejemplo, estas son maneras alternativas de construir una matriz:

-- Una matriz de 10x10, con una función lambda para las celdas.
matrix::new(10, 10, (fila, columna) => fila * 10 + columna)
-- Una matriz de 2x4, a la que le pasamos dos vectores.
matrix::rows([1, 2, 3, 4], [5, 6, 7, 8])
-- Una matriz de covarianza de cuatro series temporales.
matrix::cov(aapl, msft, dax, esx)

En un lenguaje como el de MATLAB, casi seguramente, tendríamos tres funciones globales para conseguir lo mismo. Con el truco de AUSTRA, evitamos identificadores globales, que terminan colisionando entre ellos y teniendo que adoptar nombres crípticos. Nos evitamos también las cábalas que hay que hacer en lenguajes como C# o Java para averiguar cuál es el constructor adecuado de acuerdo a los parámetros que estamos pasando. En el fondo, un constructor de AUSTRA termina llamando indistintamente a un constructor de C# o a un método estático. Lo importante es que el lenguaje nos abstrae de estos detalles de implementación.

Volviendo al ejemplo del vector, hemos utilizado un constructor que recibe el tamaño deseado, y una función lambda que devuelve los valores de cada celda. Digamos ahora, para complicarlo, que lo que queremos es la secuencia de Fibonacci. La solución más sencilla que se me ha ocurrido es permitir que la función lambda pueda tener un parámetro adicional que apunte al propio vector que estamos construyendo. Este ejemplo sigue siendo idéntico al anterior, pero ya estamos pasando un parámetro adicional en la función lambda, aunque no lo utilicemos de momento:

vector::new(1024, (i, v) => (i + 1)^2)

¿Es esto limpio y seguro? Por supuesto. El parámetro v nunca va a ser nulo, y de hecho, ya nos llega medio cocido: la primera vez que se llama la función lambda, todos sus elementos están a cero. La siguiente vez, estará asignado el primero elemento. Y así sucesivamente. Tenemos un contrato que nos garantiza el orden en que se van a inicializar los elementos del vector. Tenga presente que una posible implementación, para un vector suficientemente grande, podría usar paralelismo para rellenar segmentos concurrentemente. Pero cuando usamos esta variante del constructor, tenemos la garantía de que la inicialización va a ser secuencial.

Ahora veamos una primera versión de Fibonacci:

vector::new(1024, (i, v) =>
  if i <= 1 then 1 else v[i-1] + v[i-2])

Esto funciona perfectamente. El if de marras no es una instrucción: es una expresión condicional ternaria, como la interrogación y los dos puntos en C/C# y familia. En el caso más habitual, sumamos los dos elementos anteriores, que ya estarán inicializados. Si nos diese por hacer referencia a un elemento posterior, no sería un problema, excepto que su valor sería cero. Y la expresión condicional nos ahorra una excepción de acceso fuera de rango.

AUSTRA tiene un mecanismo más potente para estos casos, sin embargo:

vector::new(1024, (i, v) =>
  if i = 0 then 1 else v{i-1} + v{i-2})

Esta vez, tenemos un solo caso especial: el del primer elemento del vector. El cambio está en la forma en que accedemos a los elementos de v: con llaves, en vez de corchetes. Esto es lo que he llamado un safe indexer, o indexador seguro. Si el valor del índice está en rango, todo procede como de costumbre. En caso contrario, la expresión devuelve un cero. Es como si tuviésemos una memoria infinita, en la que una región de la misma puede tener valores distintos de cero, pero fuera de esa región, todo es cero. Como se trata de un lenguaje funcional, además, no existe la posibilidad de escribir en la región "externa" de la memoria del vector: no podemos hacer asignaciones ni a v[0] ni a v{v.length}, por ejemplo, aunque la primera expresión se refiera a un elemento que realmente existe.

Otras aplicaciones

En Econometría, se conoce como serie temporal autoregresiva de orden p a la que se construye de acuerdo a esta fórmula:
$$X_t = \sum_{i=1}^{p}{\phi_i X_{t - i}} + \epsilon_t$$
El símbolo $\epsilon_t$ se refiere a variables aleatorias con una distribución normal estándar. Los coeficientes $\phi_i$ son números reales que definen el comportamiento de la serie. Se trata, por supuesto, de una serie que combina ruido blanco con una retroalimentación limitado de valores anteriores. Por ejemplo, en high-frequency trading, los precios de ejecución de un activo suelen poderse representar con una serie autoregresiva, en muchos casos.

Este es el caso de uso que me ha obligado a implementar estos indexadores seguros. La forma de construir una serie autoregresiva en AUSTRA es la siguiente:

let r=vector::nrandom(1024) in
    vector::new(r.length, (i, v) =>
        r[i] + 0.7*v{i-1} + 0.1*v{i-2})

Primero construimos un vector aleatorio usando el constructor vector::nrandom. La cláusula let nos permite crear una variable local a la fórmula, a la que podemos hacer referencia en lo que queda de fórmula. El resto es fácil de adivinar: no hace falta un indexador seguro para acceder a r, pero sí a los elementos anteriores del vector que estamos construyendo.

Implementación en C#

La implementación del indexador seguro en C# puede resultar interesante, por las técnicas de "bajo nivel" que utiliza. Esta es la función de la clase Vector que implementa dicha funcionalidad:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public double SafeThis(int index) =>
    (uint)index >= values.Length
    ? 0.0
    : Unsafe.Add(
        ref MemoryMarshal.GetArrayDataReference(values),
        index);

Vector es, en AUSTRA, una estructura muy ligera que se limita a encapsular un campo values de tipo double[]. La implementación podría ser una consulta de rango, seguida de un acceso "normal" a los datos del vector:

public double SafeThis(int index) =>
    index >= 0 && index < values.Length ? values[index] : 0.0;

Pero AUSTRA es una librería que se va a utilizar en cosas que a priori no te puedes imaginar, y en estos casos, optimizar bien el código no puede calificarse de "optimización prematura".

En este caso, he usado varios trucos, que son bastante usados por el propio código de .NET Core:

  • Primero, está el truco de convertir el índice en un entero sin signo, para ahorrarnos una comparación y un salto. Si nos pasan un índice negativo, el valor reconsiderado como entero sin signo va a ser inevitablemente mayor que la longitud del array.
  • Luego está el truco indescriptiblemente sucio de usar MemoryMarshal.GetArrayDataReference para obtener la dirección de memoria donde comienza el array. Este truco sólo puede fallar si value fuese un puntero nulo, pero no es el caso.
  • Finalmente, el método Unsafe.Add suma el índice a esa posición inicial y devuelve el valor del elemento. Este es un truco más tolerable.

Al final, el compilador y el JIT generan más o menos el mismo código que para un acceso normal "verificado", pero con la particularidad de devolver cero si el índice está fuera de rango, que es lo que queríamos. No se añade código innecesario.

Una respuesta a «Safe indexers»

Deja un comentario