El formato de número Q es una notación para describir números de punto fijo binarios.
Un número de punto fijo es un patrón de diseño popular en Solidity para almacenar valores fraccionarios, ya que el lenguaje no soporta números de punto flotante. Por lo tanto, para «capturar» la porción fraccionaria de un número, multiplicamos la fracción por un número entero para que las porciones fraccionarias se conviertan en números enteros (con alguna posible pérdida de precisión).
Decimales de Ether vs Números Q
El número de punto fijo más conocido en la programación de Solidity es «Un Ether». Un Ether es en realidad unidades base (Wei). «Un» Ether es en realidad 1 multiplicado por . Dado que no podemos almacenar «0.5» en Solidity, en su lugar almacenamos o 500000000000000000. Esencialmente, se multiplica por 0.5 para que la fracción se «conserve» como un número entero. Sin embargo, esta no es una solución perfecta, ya que no puede contener valores menores que . No obstante, es lo suficientemente bueno para la mayoría de las aplicaciones, y los contratos pueden usar un número mayor si se necesita más precisión.
Un número Q, por otro lado, multiplica la fracción por una potencia de 2 en lugar de una potencia de 10 porque multiplicar (y dividir) por potencias de 2 es más eficiente en términos de gas en la EVM, ya que multiplicar o dividir por potencias de dos se puede hacer usando operaciones de desplazamiento de bits (bitshift). Por ejemplo, x << n es equivalente a x * 2**n y x >> n es equivalente a x / 2**n.
Notación de números Q
Un número Q a menudo se escribe en la forma Qm.n donde n es la potencia de 2 que se usa para multiplicar el número y m es el tamaño del entero sin signo (unsigned integer) de la porción entera. Esto es completamente equivalente a decir que m es el número de bits utilizados para almacenar la porción entera y n es el número de bits utilizados para almacenar la fracción.
Para demostrar la equivalencia, consideremos un número Q1.1. Este número asigna 1 bit para la porción entera (m = 1) y 1 bit para la fracción (n = 1). El número 1 se representaría como , lo cual es equivalente a 1 << 1. La representación binaria de o 1 << 1 es 10. Ten en cuenta que nuestro número tiene un tamaño de 2 bits (). Podríamos deconstruir el 10₂ como:
Por lo tanto, la variable que contiene «1» como un Q1.1 en realidad almacenaría el valor 2, o el binario 10₂. Sin embargo, «interpretamos» o «tratamos» el valor 2 como 1 si la variable se utiliza para almacenar un Q1.1.
Veamos un ejemplo con un número Q8.4. El número 1 se representa como o 1 << 4. La representación binaria de 1 << 4 es 10000₂. Dado que tenemos 8 bits que representan la porción del número entero, necesitamos rellenar con ceros a la izquierda (leftpad) hasta que nuestro número completo tenga un tamaño de 12 bits:
Hay un total de 12 bits almacenando el número, ya que 8 + 4 = 12. «Bajo el capó» (under the hood) en realidad estamos almacenando el valor , pero interpretamos el valor de la variable como 1. Esto es similar a cómo podríamos interpretar que una variable contiene «1 Ether» pero «bajo el capó» la variable en realidad está almacenando . Cuando decimos «bajo el capó» nos referimos al número real que está en la memoria o en el almacenamiento (storage).
Como tercer ejemplo, consideremos un número Q4.8. El número 1 se representa como o 1 << 8. La representación binaria de 1 << 8 es 10000000₂. Dado que tenemos 4 bits que representan la porción entera, rellenamos con tres ceros a la izquierda hasta que el número completo tenga un tamaño de 12 bits:
Tanto los números Q4.8 como los Q8.4 requieren 12 bits para almacenarse. Sin embargo, el número Q8.4 tiene más bits asignados a la porción entera, y por lo tanto puede almacenar un número entero más grande. Por otro lado, el número Q4.8 tiene más bits asignados a la porción fraccionaria, por lo que puede ser más preciso al representar fracciones.
El valor «1» como un número de punto fijo
En general para un número Q, «uno» es el 1 multiplicado por donde es el número de bits para almacenar la fracción. Así que 1 en Q64.96 es o 1 << 96. El «uno» en Q128.128 es o 1 << 128. El «uno» en Q64.128 también es 1 << 128 porque solo nos importa el número de bits fraccionarios.
Para cualquier Qm.n, la representación del entero 1 siempre es 1 << n, y da la casualidad de que tanto Q64.128 como Q128.128 tienen n = 128.
Ten en cuenta que n significa el número de bits binarios (base 2), no el «número de decimales» como en una potencia de diez (base 10). Qx.18 no es lo mismo que la forma en que usamos para almacenar 1 ether. Qx.18 significa que «uno» es , no .
Usando números Q en Solidity
El número de bits en un número Qm.n debe tener un tamaño de m + n bits. Por lo tanto:
- Un número Q64.64 debe al menos usar un uint128 (128 = 64 + 64)
- Un número Q64.96 debe al menos usar un uint160 (160 = 64 + 96)
- Un número Q128.128 debe usar un uint256
Interpretando la porción fraccionaria de un número Q
Consideremos todos los valores posibles de un número Q1.1:
| valor «bajo el capó» | valor flotante (valor «bajo el capó» ÷ 2^1) | |
|---|---|---|
| 0 0 | 0 | 0 |
| 0 1 | 1 | 0.5 |
| 1 0 | 2 | 1.0 |
| 1 1 | 3 | 1.5 |
Podemos ver que el «bit de fracción» establecido en 1 transmite que el único bit en la porción de la fracción representa el valor 0.5. En general, los bits de fracción representan potencias fraccionarias de dos. Veamos un caso de un número Q1.2 como ejemplo:
| valor «bajo el capó» | valor flotante (valor «bajo el capó» ÷ 2^2) | |
|---|---|---|
| 0 00 | 0 | 0 |
| 0 01 | 1 | 0.25 (1/4) |
| 0 10 | 2 | 0.5 (2/4) |
| 0 11 | 3 | 0.75 (3/4) |
| 1 00 | 4 | 1.00 (4/4) |
| 1 01 | 5 | 1.25 (5/4) |
| 1 10 | 6 | 1.50 (6/4) |
| 1 11 | 7 | 1.75 (7/4) |
En general, los bits en un número Q se interpretan de la siguiente manera. Ten en cuenta que los bits a la derecha del decimal representan potencias fraccionarias de dos:
Cada bit en la fracción representa aditivamente una potencia fraccionaria de dos. Por ejemplo, 0.1₂ representa 0.5, 0.11₂ representa 0.75, y 0.001₂ representa 0.125 o un octavo.
Los números Q solo pueden codificar fracciones que pueden representarse como sumas de 1 sobre una potencia de 2. Si intentamos representar un número como 1/3, necesariamente habrá un error de redondeo.
Intenta introducir varios valores fraccionarios en la herramienta interactiva a continuación para ver cómo se convierten en una representación de punto fijo:
Convirtiendo un número entero a un número Q
Para convertir el entero 1 en un número de punto fijo Q64.96, calculamos 1 << 96. Esto crea un número binario con un 1 en la posición 96 y ceros en los valores binarios del 0 al 95 inclusive.
En general, podemos convertir un entero en un número Qm.n desplazando hacia la izquierda (leftshifting) el entero en n bits.
La siguiente animación ilustra esto:
Convirtiendo un número Q a un número entero
Un número Q tiene una porción de fracción, pero un entero no. Así que para convertir un número Q a un entero, simplemente desplazamos los bits (bitshift) de la porción entera para que se elimine la parte fraccionaria.
Por lo tanto, si queremos extraer la porción entera de un número de punto fijo, desplazamos hacia la derecha el número en n bits (recuerda: n es el número de bits fraccionarios en Qm.n). Esto hace que los bits fraccionarios desaparezcan, dejándonos solo con la porción entera. En otras palabras, estamos convirtiendo un número Q de punto fijo en un entero al cortar todos los bits fraccionarios.
Considera la siguiente animación que convierte un número Q4.4 en un número entero:
Construyendo un valor de punto fijo
Supongamos que queremos codificar el número «1.5» como un Q64.96. Solidity no acepta 1.5 * 2**96 ni 1.5 << 96 como una sintaxis válida.
En cambio, 1.5 se puede calcular como:
1 * 2**96 + 2**96 / 2; // equivalent to 1 + 0.5
El valor almacenado «bajo el capó» para 1.5 como un número Q64.96 sería 118842243771396506390315925504. Podemos calcular esto en Python de la siguiente manera:
>>> int(1.5 * 2**96)
118842243771396506390315925504
En binario, podemos ver que se utilizan 96 bits para la fracción:
>>> bin(118842243771396506390315925504)
'0b1 100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' # space added for clarity
Convirtiendo un número Q a un número de punto flotante (off-chain)
Usando nuestro ejemplo anterior de 1.5, podemos dividir 118842243771396506390315925504 entre y obtener:
Cuando calculamos el valor flotante de un número Q off-chain, dividimos entre , no hacemos un desplazamiento de bits (bitshift) por 96. Esto se debe a que el desplazamiento de bits «destruye» los 96 bits situados más a la derecha, por lo que la información que contiene la fracción se perdería.
El valor más grande que puede contener un número Q
El valor entero más grande que un número Q puede contener es donde es el número de bits en la porción entera. El valor total más grande que puede contener es la porción entera más grande sumada a la fracción representable más grande:
Esto es matemáticamente equivalente a . Por lo tanto, el valor total más grande que un número Qm.n puede almacenar es .
Por ejemplo, el valor entero más grande de un Q96.64 es o 79228162514264337593543950335. El valor más grande que un valor Q96.64 puede almacenar, incluyendo la parte fraccionaria, es:
o
79228162514264337593543950335.9999999999999999999457898913757247782996273599565029144287109375
Sumando dos números Q
Si queremos sumar, por ejemplo, un número Q128.128 a un número Q64.64, tenemos dos opciones. La primera opción es desplazar hacia la izquierda (left bitshift) el número Q64.64 por 64 bits para que los puntos decimales se alineen. Esto efectivamente convierte un número Q64.64 en un número Q64.128.
Alternativamente, tenemos una segunda opción. Si no necesitamos 128 bits de precisión, o queremos específicamente un número Q64.64 como resultado de la suma, podemos desplazar hacia la derecha (right bitshift) el número Q128.128 en 64 bits, lo que lo convierte en un número Q128.64. Por supuesto, esto resulta en alguna pérdida de precisión.
Para sumar dos números Q, solo necesitamos que tengan el mismo número de bits en la porción fraccionaria, es decir, los puntos decimales deben estar alineados. Sin embargo, el tipo de datos de «destino» debe tener una porción de número entero lo suficientemente grande como para manejar la suma, o podríamos tener un desbordamiento (overflow).
Si queremos restar dos números Q, seguimos la misma lógica descrita en esta sección.
Multiplicando o dividiendo
Si multiplicamos esperamos obtener como respuesta. Sin embargo, bajo el capó, el número se representa como . Por lo tanto, si multiplicamos el número 1 representado como un Q64.96 por sí mismo, en realidad estaríamos llevando a cabo la operación , pero en realidad queremos como respuesta.
Por lo tanto, cuando multiplicamos dos números Q juntos, debemos continuar desplazando el producto hacia la derecha en n bits. En nuestro ejemplo de 96 bits, esto significa que sería desplazado a la derecha (rightshifted) en 96 bits para convertirse en .
Algunos ejemplos de punto fijo en Solidity
Ejemplo 1: Dividir 5 entre 2
Supongamos que queremos dividir 5 entre 2 y devolver el resultado como un número Q64.64. Dado que el número Q64.64 no puede contener enteros más grandes que 64 bits, podemos representar los enteros 5 y 2 utilizando uint64. De hecho, contener un Q64.64 requiere 128 bits, por lo que usaremos un uint128 para almacenar Q64.64.
En otras palabras, los enteros se representan con uint64 ya que ese es el entero más grande que un Q64.64 puede contener, pero el número Q64.64 se representa con un uint128 ya que necesita contener 64 bits para el entero y 64 bits para la fracción.
function divToQ64x64(uint64 x, uint64 y) public pure returns (uint128) {
// convert x (a uint64 integer)
// to a Q64.64 fixed-point number by left-shifting 64 bits.
uint128 x64_64 = x << 64;
// divide by y
return x64_64 / y;
}
divToQ64x64(5, 2); // returns 46116860184273879040
El resultado 46116860184273879040 se codifica como 2.5 porque 46116860184273879040 / 2**64 = 2.5.
Ejemplo 2: Multiplicar 5 por 0.5
Multipliquemos 5 por 0.5 cuando ambos se representan como Q64x64. Las representaciones son las siguientes:
- 5 como número Q64.64 es
5 << 64o5 * 2**64que es igual a 92233720368547758080. - 0.5 como número Q64.64 es
1 << 64 / 2o2**64 / 2que es igual a 9223372036854775808
Cuando multiplicamos dos números de punto fijo, debemos asegurarnos de que no se desborden (overflow) antes de dividir por para normalizarlos nuevamente. Esto significa que almacenaremos el producto en un uint256 y luego lo desplazaremos hacia la derecha en 64 bits.
function mulU64x64(uint128 x, uint128 y) public pure returns (uint128) {
// note: Solidity performs multiplication using uint128 unless
// explicitly upcasted. This could overflow and revert.
uint256 temp = uint256(x) * uint256(y);
return uint128(temp >> 64);
}
mulU64x64(5 * 2**64, 2**64 / 2) // returns 46116860184273879040
Esto devuelve el resultado esperado ya que 46116860184273879040 / 2**64 = 2.5.
Ejemplo 3: Multiplicar 5 por 0.5, pero 5 es un entero en lugar de un punto fijo
Como variación del ejemplo anterior, queremos multiplicar 5 (un entero) por 0.5 (un número de punto fijo) y devolver un número de punto fijo. La única diferencia con el código anterior es la conversión del 5 a su representación de punto fijo:
function mulUint64ByQ64x64(uint64 x, uint128 y) public pure returns (uint128) {
// convert uint64 to fixed point
uint128 x_fp = uint128(x) << 64;
uint256 temp = uint256(x_fp) * uint256(y);
return uint128(temp >> 64);
}
mulUint64ByQ64x64(5, 2**64 / 2) // returns 46116860184273879040
El resultado sigue siendo el equivalente de punto fijo de 2.5.
Es un poco ineficiente desplazar a la izquierda por 64 y luego desplazar a la derecha más tarde. El mismo cálculo anterior se puede hacer de manera más eficiente como:
function mulUint64ByQ64x64(uint64 x, uint128 y) public pure returns (uint128) {
return uint128(uint256(x) * uint256(y));
}
mulUint64ByQ64x64(5, 2**64 / 2) // returns 46116860184273879040
Dividiendo números Q
Si calculamos 1 ÷ 1 esperamos que el resultado sea 1. Supongamos que estamos utilizando Q64.64. «1» es . Si calculamos obtenemos 1 como resultado, no . Para corregir esto, podríamos desplazar el resultado hacia la izquierda en n bits, pero esto viola el principio de «multiplicar antes de dividir para evitar la pérdida de precisión». Por lo tanto, la forma correcta de dividir dos números Q es primero desplazar el numerador hacia la izquierda y luego realizar la división:
function divQ64x64ByQ64x64(uint128 x, uint128 y) public pure returns (uint128) {
return uint128(uint256(x) << 64 / uint256(y));
}
divQ64x64ByQ64x64(5, 2**64 / 2) // returns 46116860184273879040
Resumen
- Los números Q son un patrón de diseño para contener números fraccionarios en Ethereum.
- Son más eficientes que la representación decimal de Ethereum, ya que la multiplicación y la división se pueden lograr con desplazamientos de bits (bitshifting).
- Un número Q se representa como Qm.n, donde
mes el número de bits para el entero ynes el número de bits para la fracción. - Cada bit después del decimal representa el valor 1/2, 1/4, 1/8, … etc.
- Un entero se puede convertir en un número Q haciendo un desplazamiento de bits a la izquierda en
nbits. Un número Q se puede convertir en un entero truncando los bits de la fracción, o desplazándolos hacia la derecha ennbits. - Si dividimos un número Q por en un lenguaje que soporte puntos flotantes, podemos ver la representación fraccionaria prevista.
- Dados dos enteros
ayb, asegúrate de queaquepa dentro dembits. Calcula su proporción como un número Qm.n mediantea << n / b. El número de bits utilizado para contener el número de punto fijo resultante debe ser el número de bits dea(m) másn, es decir, Qm.n. - Los números Q se pueden sumar «tal cual» siempre que los decimales estén alineados.
- Si multiplicamos dos números Q, necesitamos desplazar el resultado hacia la derecha en
npara que el resultado tengandecimales. - Si dividimos dos números Q, primero debemos desplazar el numerador hacia la izquierda en
n. - Tanto la división como la multiplicación deben tener cuidado de evitar desbordamientos temporales (overflow).