Trabajando con bytes en Dart

maginkgo

Marcos Alejandro

Posted on January 13, 2021

Trabajando con bytes en Dart

Si puedes entender los bytes, puedes entender cualquier cosa.

Alt Text

Esta es una traducción al español del artículo Working with bytes in Dart del autor Suragch.

Prefacio

Empecé con este tema porque estaba investigando cómo comunicarme entre Dart y un servidor de base de datos PostgreSQL. Resultó ser mucho más de bajo nivel de lo que esperaba. Pensé en escribir un artículo corto explicando algunas de las nuevas cosas que estaba aprendiendo. Bueno, tres días completos después, ese corto artículo se ha convertido en una de las explicaciones más profundas que he escrito sobre un tema que ya es un nicho. Aunque el artículo es largo, no creo que lo encuentres aburrido, y es muy probable que aprendas una o dos cosas nuevas sobre Dart aunque lo hayas estado usando durante un tiempo. Desde luego que sí. Como siempre, por favor, hágame saber si encuentra algún error. Así es como aprendo. Y pruebe los ejemplos de código usted mismo. Así es como se aprende.

Este artículo está actualizado para Dart 2.10.

Bits y bytes

Todos los que leen este artículo saben que un byte es ocho bits:

00101010
Enter fullscreen mode Exit fullscreen mode

Ese byte de 8-bit tiene un valor, un valor de 42 en este caso, que es sólo un integer. Ahora, combinando ese conocimiento con el hecho de que todos los datos binarios son sólo una secuencia de bytes, esto significa que es posible representar cualquier dato binario como una lista de enteros en Dart:

List<int> data = [102, 111, 114, 116, 121, 45, 116, 119, 111, 0];
Enter fullscreen mode Exit fullscreen mode

Esos bytes pueden ser de un archivo, una imagen de mapa de bits, una grabación mp3, un memory dump, una solicitud de red, o los códigos de caracteres de una cadena. Todos son bytes.

Siendo un poco más eficiente

En Dart el tipo int tiene un valor por defecto de 64-bit. Eso son ocho bytes. Aquí está el número 42 de nuevo, esta vez mostrando los 64 bits para su referencia visual:

0000000000000000000000000000000000000000000000000000000000101010
Enter fullscreen mode Exit fullscreen mode

Si miras con cuidado, puedes notar que muchos de esos bits no están siendo usados.

Un int puede almacenar valores tan grandes como 9.223.372.036.854.775.807, pero lo más grande que será un byte es 255. Este es definitivamente un caso de usar una escopeta para matar una mosca. Ahora imagina un simple archivo de 1 megabyte (1024 bytes) como una lista de enteros. Tu problema se acaba de hacer un millón de veces más grande.

Aquí es donde entra en juego Uint8List. Este tipo es básicamente como List<int>, pero es mucho más eficiente que para listas grandes. Uint8List es una lista de números enteros en la que los valores son sólo 8 bits cada uno, o 1 byte. La U de Uint8List significa unsigned, por lo que los valores van de 0 a 255. ¡Eso es perfecto para representar datos binarios!

Nota al margen: Los números negativos en binario

¿Has pensado alguna vez en cómo representar los números negativos en binario? Bueno, la forma de hacerlo es hacer que el bit ubicado más a la izquierda sea un 1 para los negativos y un 0 para los positivos. Por ejemplo, estos 8-bit enteros con signo son todos negativos porque todos comienzan con 1:

11111101
10000001
10101010
Enter fullscreen mode Exit fullscreen mode

Por otro lado, los siguientes números enteros de 8-bit con signo son todos positivos porque el bit ubicado más a la izquierda es 0 :

01111111
00000001
01010101
Enter fullscreen mode Exit fullscreen mode

Podrías pensar que si 00000001 es +1, entonces 10000001 debería ser -1. Sin embargo, no funciona así. De lo contrario, tendrías dos valores para el cero: 10000000 y 00000000. La solución es usar un sistema llamado Two's Complement. El siguiente vídeo lo explica muy bien si estas interesado.

https://youtu.be/mRvcGijXI9w

En Dart ya se puede utilizar el tipo int para representar valores con signo, tanto positivos como negativos. Sin embargo, si quieres listas de sólo 8-bit enteros con signo, puedes usar el tipo Int8List (nota la ausencia de U). Esto permite valores de -128 a 127. Sin embargo, esto no es particularmente útil para la mayoría de los casos de uso, así que nos quedaremos con los enteros signo en Uint8List.

Convirtiendo List<int> a Uint8List

Uint8List forma parte de dart:typed_data, una de las principales bibliotecas de Dart. Para utilizarla, añada la siguiente importación:

import 'dart:typed_data';
Enter fullscreen mode Exit fullscreen mode

Ahora puede convertir la lista List<int> que tenía antes en Uint8List utilizando el método fromList:

List<int> data = [102, 111, 114, 116, 121, 45, 116, 119, 111, 0];

Uint8List bytes = Uint8List.fromList(data);
Enter fullscreen mode Exit fullscreen mode

Si alguno de los valores de la lista List<int> está fuera del rango de 0 a 255, entonces los valores se ajustarán. Puedes ver eso en el siguiente ejemplo:

List<int> data = [3, 256, -2, 2348738473];

Uint8List bytes = Uint8List.fromList(data); 

print(bytes); // [3, 0, 254, 169]
Enter fullscreen mode Exit fullscreen mode

256 fue uno más allá de 255, así que se convirtió en 0. El siguiente número, -2, es menos que 0, así que retrocedió dos posiciones en la parte superior y se convirtió en 254. Y no tengo ni idea de cuántas vueltas dio el 2348738473, pero finalmente terminó en 169.

Si no quieres que se produzca este comportamiento, puedes usar Uint8ClampedList en su lugar. Esto sujetará todos los valores mayores de 255 a 255 y todos los valores menores de 0 a 0.

List<int> data = [3, 256, -2, 2348738473];

Uint8ClampedList bytes = Uint8ClampedList.fromList(data);

print(bytes); // [3, 255, 0, 255]
Enter fullscreen mode Exit fullscreen mode

Esta vez los tres valores finales fueron fijados.

Creando un Uint8List

En el método anterior se creó un Uint8List convirtiendo un List<int>. Pero, ¿y si sólo quieres empezar con una lista de bytes? Puedes hacerlo pasando la longitud de la lista al constructor así:

final byteList = Uint8List(128);
Enter fullscreen mode Exit fullscreen mode

Esto crea una lista de longitud fija donde los 128 valores son 0. Imprime byteList y verás:

[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Enter fullscreen mode Exit fullscreen mode

Siéntase libre de contarlos para asegurarse de que realmente hay 128.

Modificando una lista de bytes

¿Cómo se modifican los valores de un Uint8List? Como lo harías con una lista normal:

byteList[2] = 255;
Enter fullscreen mode Exit fullscreen mode

La impresión byteList muestra de nuevo que el valor de index 2 ha cambiado:

[0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Enter fullscreen mode Exit fullscreen mode

La naturaleza de acceso aleatrorio de las listas hace que sea rápido y conveniente modificar el valor del byte en cualquier ubicación del index.

Listas ampliables (growable)

La lista que hizo arriba fue fixed-length. Si intentabas hacer algo como esto:

final byteList = Uint8List(128);

byteList.add(42);
Enter fullscreen mode Exit fullscreen mode

...obtendrías la siguiente excepción:

Unsupported operation: Cannot add to a fixed-length list
Enter fullscreen mode Exit fullscreen mode

Eso no es muy conveniente si estás tratando de hacer algo como recoger los bytes de un stream. Para hacer una lista de bytes growable, necesitas un BytesBuilder:

final bytesBuilder = BytesBuilder();
bytesBuilder.addByte(42);
bytesBuilder.add([0, 5, 255]);
Enter fullscreen mode Exit fullscreen mode

Se puede añadir un solo bytes (del tipo int) o listas de bytes (del tipo List<int>).

Cuando quiera convertir el constructor a Uint8List, utilice el método toBytes así:

Uint8List byteList = bytesBuilder.toBytes();
print(byteList); // [42, 0, 5, 255]
Enter fullscreen mode Exit fullscreen mode

Más de una forma de ver los bits

Bits y bytes son sólo un montón de ceros y unos. No tienen mucho significado si no tienes una forma de interpretarlos:

0011111100011101110011011101101110011111100110011011110000010101001110100010101010000111100101101111001111011100000101011001101111010101101000011101100101000111100000100100011111101011111110100111010011001100000000000111111010010010000000001111010001100000 
Enter fullscreen mode Exit fullscreen mode

Con Uint8List la forma en que los interpretamos es diciendo que cada 8 bits hay un número entre 0 y 255:

00111111 00011101 11001101 11011011 10011111 10011001 10111100 00010101 00111010 00101010 10000111 10010110 11110011 11011100 00010101 10011011 11010101 10100001 11011001 01000111 10000010 01000111 11101011 11111010 01110100 11001100 00000000 01111110 10010010 00000000 11110100 01100000 
Enter fullscreen mode Exit fullscreen mode

Podrías reinterpretar esos mismos valores como números enteros con signo de -128 a 127. Eso es lo que hicimos con Int8List.

Sin embargo, también hay otras formas de ver los datos. Por ejemplo, en lugar de usar trozos de 8-bit, podrías mirar los datos como una lista de trozos de 16-bit:

0011111100011101 1100110111011011 1001111110011001 1011110000010101 0011101000101010 1000011110010110 1111001111011100 0001010110011011 1101010110100001 1101100101000111 1000001001000111 1110101111111010 0111010011001100 0000000001111110 1001001000000000 1111010001100000
Enter fullscreen mode Exit fullscreen mode

La biblioteca dart:typed_data tiene tipos para eso también. Usa Uint16List para los enteros unsigned que van de 0 a 65.535, o Int16List para los enteros con signo que van de -32.768 a 32.767.

No se detiene ahí. También podrías interpretar esos mismos datos como una lista de valores 32-bit:

00111111000111011100110111011011 10011111100110011011110000010101 00111010001010101000011110010110 11110011110111000001010110011011 11010101101000011101100101000111 10000010010001111110101111111010 01110100110011000000000001111110 10010010000000001111010001100000
Enter fullscreen mode Exit fullscreen mode

O los valores 64-bit:

0011111100011101110011011101101110011111100110011011110000010101 

0011101000101010100001111001011011110011110111000001010110011011 

1101010110100001110110010100011110000010010001111110101111111010 

0111010011001100000000000111111010010010000000001111010001100000
Enter fullscreen mode Exit fullscreen mode

O incluso valores de 128-bit:

00111111000111011100110111011011100111111001100110111100000101010011101000101010100001111001011011110011110111000001010110011011 

11010101101000011101100101000111100000100100011111101011111110100111010011001100000000000111111010010010000000001111010001100000
Enter fullscreen mode Exit fullscreen mode

Dart tiene tipos para todos ellos:

  • Int32List
  • Uint32List
  • Int64List
  • Uint64List
  • Int32x4List (128 bits)

Usar el tipo de lista específica que necesita será más eficiente para grandes cantidades de datos que usar List<int>.

Vistas en bytes en Dart

Dart respalda raw binary data con un ByteBuffer. Todos los tipos que se vieron en la última sección implementan una clase llamada TypedData, que es sólo una forma genérica de ver los datos en el ByteBuffer. Esto significa que tipos como Uint8List, Int32List, y Uint64List son todas formas diferentes de ver los mismos datos.

Usaremos la siguiente lista de cuatro bytes para los ejemplos que vienen:

00000001 00000000 00000000 10000000
Enter fullscreen mode Exit fullscreen mode

En forma decimal la lista se vería así:

1, 0, 0, 128
Enter fullscreen mode Exit fullscreen mode

Primero crea la lista en Dart como lo has hecho anteriormente:

Uint8List bytes = Uint8List.fromList([1, 0, 0, 128]);
Enter fullscreen mode Exit fullscreen mode

Como con cualquier forma de TypedData, se puede acceder a la propiedad subyacente ByteBuffer de un Uint8List accediendo a la propiedad buffer:

ByteBuffer byteBuffer = bytes.buffer;
Enter fullscreen mode Exit fullscreen mode

Unsigned 16-bit view

Ahora que tienes el byte buffer, puedes obtener una vista diferente de los bytes usando uno de los métodos as..., esta vez asUint16List:

Uint16List sixteenBitList = byteBuffer.asUint16List();
Enter fullscreen mode Exit fullscreen mode

Antes de imprimir sixteenBitList para ver el contenido, ¿cuáles cree que serán los valores?

¿Crees que sabes la respuesta? Bien, imprime la lista:

print(sixteenBitList);
Enter fullscreen mode Exit fullscreen mode

En mi ordenador Mac los resultados son los siguientes:

[1, 32768]
Enter fullscreen mode Exit fullscreen mode

Eso es muy extraño. Ya que los valores originales eran:

00000001 00000000 00000000 10000000
1        0        0        128
Enter fullscreen mode Exit fullscreen mode

Hubiera esperado que se combinaran en trozos de 16-bit como este:

0000000100000000 0000000010000000
256              128
Enter fullscreen mode Exit fullscreen mode

En cambio, tenemos esto:

0000000000000001 1000000000000000
1                32768
Enter fullscreen mode Exit fullscreen mode

Mantén ese pensamiento. Revisemos la vista de 32-bit.

Unsigned 32-bit view

Empezaré con la lista de bytes que contiene los valores decimales 0, 1, 2, 3. De esta manera podemos ver si están en el mismo orden o no. Para mayor claridad, aquí está el aspecto que tendrá la lista original en forma binaria 8-bit:

00000000 00000001 00000010 00000011
0        1        2        3
Enter fullscreen mode Exit fullscreen mode

Ahora ejecuta el siguiente código:

Uint8List bytes = Uint8List.fromList([0, 1, 2, 3]);
ByteBuffer byteBuffer = bytes.buffer;
Uint32List thirtytwoBitList = byteBuffer.asUint32List();
print(thirtytwoBitList);
Enter fullscreen mode Exit fullscreen mode

Esta vez tienes una vista 32-bit en el buffer subyacente del Uint8List original. La declaración impresa revela un valor de 50462976, que en 32-bit binario es:

00000011000000100000000100000000
Enter fullscreen mode Exit fullscreen mode

o si se añade un espacio (para ayudar a ver las partes):

00000011 00000010 00000001 00000000
Enter fullscreen mode Exit fullscreen mode

¡Eso es exactamente el orden inverso al original! ¿Qué es lo que está pasando?

Endianness

Normalmente no tienes que pensar en este tipo de cosas cuando todo lo que haces es construir felizmente una aplicación Flutter, pero cuando caes tan bajo como lo estamos haciendo hoy, en realidad estás chocando con la arquitectura de la máquina.

Algunas máquinas ordenan los bytes individuales dentro de un trozo de bytes (ya sean trozos de 2-byte, trozos de 4-byte o trozos de 8-byte) en orden ascendente. Esto se llama big endian porque el byte más significativo es el primero. Eso es normalmente lo que esperamos porque leemos los números de izquierda a derecha, empezando por la parte más grande del número.

Sin embargo, otras máquinas ordenan los bytes individuales dentro de un trozo de bytes en orden inverso. Esto se llama little endian. Aunque parezca poco intuitivo, funciona en el nivel de la arquitectura de la máquina. El problema viene cuando big endian se encuentra con little endian. Y eso es lo que nos pasó a nosotros arriba.

Antes de hablar más sobre eso, quizás quieran ver este video en endianess. La explicación es bastante buena (si puedes pasar de la pequeña historia del principio):

https://youtu.be/_wk_nZVuY0Q

Donde ocurrió el malentendido

Cuando corrí el código:

Uint8List bytes = Uint8List.fromList([0, 1, 2, 3]);

ByteBuffer byteBuffer = bytes.buffer;

Uint32List thirtytwoBitList = byteBuffer.asUint32List();

print(thirtytwoBitList);
Enter fullscreen mode Exit fullscreen mode

Dart tomó la lista de estos cuatro bytes:

00000000 00000001 00000010 00000011
Enter fullscreen mode Exit fullscreen mode

Y se las dio a mi ordenador. Dart sabía que 00000000 era el primero, 00000001 era el segundo, 00000010 era el tercero y 00000011 era el último. Mi ordenador también lo sabía.

Entonces Dart pidió a mi computadora una vista de trozos de 32 bits, de esa lista de bytes, del buffer de bytes de la memoria. Mi computadora devolvió:

00000011000000100000000100000000
Enter fullscreen mode Exit fullscreen mode

Bueno, resulta que mi MacBook tiene una arquitectura little endian. Mi ordenador todavía sabía que 00000000 era el primero, 00000001 era el segundo, 00000010 era el tercero y 00000011 era el último. Sin embargo, cuando la declaración print recurrió a algún método toString en algún lugar, interpretó esos 32 bits como un solo integer en formato big endian. No lo culpo.

Comprobando endianness en Dart

Si quiere saber cuál es la arquitectura endian de su sistema, puede ejecutar el siguiente código en Dart:

print(Endian.host == Endian.little);
Enter fullscreen mode Exit fullscreen mode

Si eso es true, entonces usted también tiene una little endian. De lo contrario, es big endian. Lo más probable es que la suya sea little endian, ya que la mayoría de los ordenadores personales de hoy en día la utilizan.

Si miran el código fuente de la clase Endian, verán que la forma en que comprueba la arquitectura del host es muy similar a la que me llevó a mi sorpresa inicial:

class Endian {
  ...
  static final Endian host =
      (new ByteData.view(new Uint16List.fromList([1]).buffer))
        .getInt8(0) == 1
          ? little
          : big;
}
Enter fullscreen mode Exit fullscreen mode

Toma una representación 16-bit de 1 y luego ve si los primeros 8 bits son 1, en cuyo caso es little endian. Eso es porque los little endian ordenan esos 16 bits así:

00000001 00000000
Enter fullscreen mode Exit fullscreen mode

mientras que las máquinas big endian las ordenan así:

00000000 00000001
Enter fullscreen mode Exit fullscreen mode

Por qué debería importarme?

Puede que pienses que no necesitas preocuparte por esto, ya que no planeas acercarte tanto a bajo nivel. Sin embargo, endianness aparece en otros contextos, también. Básicamente, cada vez que trabajes con datos compartidos que lleguen en algo más grande que trozos de 8-bit, debes tener en cuenta endianess.

Por ejemplo, si has visto el vídeo que he enlazado arriba, sabes que los diferentes formatos de archivo utilizan una codificación diferente de endian:

  • JPEG (big endian)
  • GIF (little endian)
  • PNG (big endian)
  • BMP (little endian)
  • MPEG-4 (big endian)
  • Network data (big endian)
  • UTF-16 text files (big or little endian — watch this video)

Así que incluso si conoces el endianness del raw byte data con el que estás trabajando, ¿cómo le comunicas eso al Dart para que no tengas el mismo problema de falta de comunicación que yo tuve antes?

Sigue leyendo para averiguarlo.

Manejando endianness en Dart

Una vez que se tienen los datos en bruto como alguna clase TypedData como podría ser Uint8List, se puede usar la clase ByteData para hacer tareas de acceso de lectura y escritura en ella. De esta manera no tienes que interactuar con el BytesBuffer directamente. ByteData también permite especificar el endianess con el que se desea interpretar los datos.

Escriba las siguientes líneas de código:

Uint8List byteList = Uint8List.fromList([0, 1, 2, 3]);

ByteData byteData = ByteData.sublistView(byteList);

int value = byteData.getUint16(0, Endian.big);

print(value);
Enter fullscreen mode Exit fullscreen mode

Esto es lo que hace:

  • Obtienes tus datos tipados Uint8List de una lista de números enteros. Esto es lo mismo que has visto anteriormente.
  • Luego lo envuelves con ByteData. Una view (o "sublist" view) es una forma de ver los datos en el buffer. La razón por la que se llama sublista es que no es necesario tener una vista de todo el buffer, que podría ser muy grande. Es posible ver un rango más pequeño de bytes dentro del buffer. En nuestro caso, sin embargo, el buffer para byteList sólo incluye esos cuatro bytes, así que estamos viendo todo.
  • Después de eso se accede a 16 bits (o dos bytes) comenzando en index 0, donde la indexación es en incrementos de one-byte. Si eliges 1 como index obtendrás el segundo y tercer bytes, y si eliges 2 como index obtendrás el tercero y cuarto bytes. Cualquier index más alto que 2 en este caso arrojaría un error ya que sólo hay cuatro bites disponibles.
  • Por último, también se le dice a Dart que se desea interpretar esos dos bytes que comienzan en index 0 como big endian. Ese es el valor por defecto de ByteData, así que podrías haber omitido ese parámetro.

Ejecuta el código y verás una salida de 1. Eso tiene sentido porque

00000000 00000001
Enter fullscreen mode Exit fullscreen mode

interpretado de manera big endian tiene un valor de 1.

Hágalo de nuevo y sustituya Endian.big por Endian.little. Ahora, cuando ejecute el código verá 256 porque está interpretando el segundo byte (00000001) como anterior al primero (00000000), así que obtendrá

0000000100000000
Enter fullscreen mode Exit fullscreen mode

que no es lo que quieres para estos datos, así que cambia el código de nuevo a Endian.big.

Setting bytes in ByteData

En la sección de arriba estabas recibiendo bytes. También puedes settear bytes mientras especificas el endianess de casi la misma manera en que los obtuviste. Añade la siguiente línea a lo que escribiste anteriormente:

byteData.setInt16(2, 256, Endian.big);

print(byteList);
Enter fullscreen mode Exit fullscreen mode

Aquí está poniendo dos bytes (con un valor de 256) comenzando en index 2 en el buffer. Imprime eso y verás:

[0, 1, 1, 0]
Enter fullscreen mode Exit fullscreen mode

¿Tiene sentido? Anteriormente el buffer contenía:

[0, 1, 2, 4]
Enter fullscreen mode Exit fullscreen mode

Pero reemplazaste los dos últimos bytes con 256, que en binario es:

00000001 00000000
Enter fullscreen mode Exit fullscreen mode

o 1 y 0, cuando se interpreta de manera big endian.

Bien, es suficiente sobre endianness. La mayoría de las veces puedes aceptar el valor por defecto de big endian y no pensar en ello.

Hexadecimal y binario

Hasta ahora he estado escribiendo todos los números binarios de este artículo como strings como 10101010, pero no es muy conveniente usar ese formato al escribir el código porque Dart tiene por defecto la base 10 para los números enteros.

int x = 10000000;
Enter fullscreen mode Exit fullscreen mode

Son diez millones, no 128.

Aunque puede ser difícil escribir directamente en binario, es bastante fácil escribir directamente valores hexadecimales en Dart. Sólo hay que anteponer al número hex el prefijo 0x así:

int x = 0x80;  // 10000000 binary
print(x);      // 128 decimal
Enter fullscreen mode Exit fullscreen mode

Como la relación entre binario y hex es tan directa, eso hace que convertir entre los dos sea una tarea fácil. Si trabajas mucho con binario, puede que valga la pena memorizar la tabla de conversión:

hex  binary 
----------
0    0000
1    0001
2    0010
3    0011
4    0100
5    0101
6    0110
7    0111
8    1000
9    1001
A    1010
B    1011
C    1100
D    1101
E    1110
F    1111
Enter fullscreen mode Exit fullscreen mode

Los valores de los bytes siempre estarán en dos trozos de valor hex (o nibbles como se llama técnicamente a un trozo de 4-bit, oh el humor de esos primeros programadores).

Aquí hay algunos ejemplos más. Si cubres el binario y sólo miras el hex, ¿puedes descifrar el binario sin mirar?

hex       binary 
----------------
80        1000 0000
2A        0010 1010
F2F2F2    1111 0010 1111 0010 1111 0010
1F60E     0001 1111 0110 0000 1110
Enter fullscreen mode Exit fullscreen mode

Como trivialidad, el tercer valor es el valor RGB (red green blue) hex para el tono de gris que Medium utiliza como backgroundd color para los bloques de código. Y el último es el punto de código Unicode del emoji con cara sonriente y gafas de sol 😎.

Convirtiendo hex y binario strings

Un truco divertido en Dart que tal vez no conozcas es que puedes convertir entre diferentes bases y obtener la representación en cadena de los valores.

Aquí hay un ejemplo de tomar un número decimal y convertirlo a hex y binario en forma de cadena:

String hex = 2020.toRadixString(16).padLeft(4, '0');
print(hex); // 07e4

String binary = 2020.toRadixString(2);
print(binary); // 11111100100
Enter fullscreen mode Exit fullscreen mode

Notas:

  • Radix sólo significa la base.
  • El primer ejemplo toma el número decimal 2020, lo convierte en base-16 (es decir, hexadecimal), y se asegura de que la longitud sea 4. Cualquier longitud menor de 4 se rellena con ceros a la izquierda. Esto hizo que 7e4 se convirtiera en 07e4.
  • El segundo ejemplo convierte el decimal 2020 a binario en el formato String.

Puedes hacer la conversión de la otra manera usando int.parse:

int myInt = int.parse('07e4', radix: 16);
print(myInt); // 2020

myInt = int.parse('11111100100', radix: 2);
print(myInt); // 2020
Enter fullscreen mode Exit fullscreen mode

Convertir Unicode strings en bytes y volver

Cuando digo bytes aquí, sólo me refiero a números. El tipo String en Dart es una lista de números Unicode. Los números Unicode se llaman puntos de código y pueden ser tan pequeños como 0 o tan grandes como 10FFFF. Aquí hay un ejemplo:

character    Unicode code point (hex)
-------------------------------
H            48
e            65
l            6C
l            6C
o            6F
😎           1F60E
Enter fullscreen mode Exit fullscreen mode

La palabra que Dart usa para los puntos de código es runes. Puedes verlos así:

Runes codePoints = 'Hello😎'.runes;

print(codePoints); // (72, 101, 108, 108, 111, 128526)
Enter fullscreen mode Exit fullscreen mode

Esa es la versión decimal de sus valores hex.

Runes son un iterable de valores int. Sin embargo, tal y como se vio al principio del artículo con List<int> y Uint8List, 64 o incluso 32 bits no es muy eficiente para almacenar texto cuando la mayoría de los caracteres del mundo de habla inglesa (además de emojis como 😎) sólo toman 8 bits. Incluso la mayoría de los miles y miles de caracteres chinos que existen pueden ser representados con menos de 16 bits.

Por esa razón, la mayoría de las personas representan valores Unicode utilizando un sistema de codificación 8-bit o 16-bit. Cuando un valor Unicode es demasiado grande para caber en 8 o 16 bits, el sistema utiliza un truco especial para codificar valores más grandes. El sistema de codificación de 8-bit se llama UTF-8 y el sistema de 16-bit se llama UTF-16.

La forma en que UTF-8 y UTF-16 son codificados es súper interesante, especialmente los trucos que usan para codificar grandes puntos de Unicode. Si te interesa ese tipo de cosas (y probablemente lo estés si sigues leyendo), definitivamente deberías ver el siguiente video. Es el mejor que he visto sobre el tema:

https://youtu.be/HhUuzFXdyNs

Conversión UTF-16 en Dart

Aunque los puntos de código de Unicode están disponibles como Iterable cuando se pide runes, Dart utiliza UTF-16 como su codificación real para strings internamente. Estos valores 16-bit se denominan unidades de código (codeUnits) en lugar de puntos (codePoints) de código.

La conversión para obtener las unidades de código UTF-16 de una cadena es fácil:

List<int> codeUnits = 'Hello😎'.codeUnits;
print(codeUnits); // [72, 101, 108, 108, 111, 55357, 56846]
Enter fullscreen mode Exit fullscreen mode

Notas:

  • Podrías estar pensando, "Hey, un int es 64 bytes, no 16!" Así es como se representa externamente después de hacer la conversión. El tipo int se supone que es genérico y no debes pensar en cuántos bytes utiliza. Internamente, strings son listas de entenros de 16-bit.
  • Pueden ver que se necesitaron dos valores UTF-16 para representar a 😎: 55357 y 56846 , que es D83D y DE0E en hex. Estos dos números se llaman surrogate pair, de los que sabrán todo si han visto el vídeo de arriba.
Decimal   Hex   Binary
--------------------------------
55357     D83D  1101100000111101 (high surrogate)
56846     DE0E  1101111000001110 (low surrogate)
String  Hex     Binary
----------------------------------------
         F60E   0000 1111 0110 0000 1110
      + 10000   0001 0000 0000 0000 0000
----------------------------------------
😎      1F60E   0001 1111 0110 0000 1110
Enter fullscreen mode Exit fullscreen mode

Como cada unidad de código UTF-16 tiene la misma longitud, eso también hace súper fácil acceder a cualquier unidad de código por su index:

int index = 0;
int codeUnit = 'Hello😎'.codeUnitAt(index);
print(codeUnit); // 72
Enter fullscreen mode Exit fullscreen mode

La conversión de ir de unidades de código a un String también es fácil:

List<int> codeUnits = 'Hello😎'.codeUnits;
final myString = String.fromCharCodes(codeUnits);
print(myString); // Hello😎
print(String.fromCharCode(72)); // H
Enter fullscreen mode Exit fullscreen mode

Por muy bonito que sea, hay algunos problemas con la manipulación de strings basada en UTF-16. Por ejemplo, si estás borrando programáticamente una unidad de código a la vez, hay una buena posibilidad de que te olvides de los surrogate pairs (y grapheme clusters). Smiley ya no es tan smiley cuando eso sucede. Lee los siguientes artículos para saber más sobre este tema:

Convertir UTF-8 strings

Mientras que Dart utiliza la codificación UTF-16, Internet utiliza la codificación UTF-8. Esto significa que cuando se transfiere un texto a través de la red, es necesario convertir los strings de Dart a y desde una lista de números enteros UTF-8 encoded. De nuevo, recuerde que esto es sólo una lista de números binarios 8-bit, específicamente un Uint8List. La ventaja de los valores de 8-bit es que no tienes que preocuparte por endianess.

Si vieras el video que enlacé arriba y ahora entiendes cómo funciona la codificación UTF-8, podrías escribir el codificador y decodificarlo tú mismo. Sin embargo, no es necesario, porque Dart ya los tiene en su biblioteca dart:convert.

Importa la biblioteca así:

import 'dart:convert';
Enter fullscreen mode Exit fullscreen mode

Entonces puedes hacer la conversión de una cadena a UTF-8 simplemente así:

Uint8List encoded = utf8.encode('Hello😎');
print(encoded); // [72, 101, 108, 108, 111, 240, 159, 152, 142]
Enter fullscreen mode Exit fullscreen mode

Esta vez Smiley es codificado como cuatro valores de 8-bit :

Decimal   Hex   Binary
------------------------
240       F0    11110000
159       9F    10011111
152       98    10011000
142       8E    10001110
String  Hex     Binary
------------------------
😎      1F60E   00001 1111 0110 0000 1110
Enter fullscreen mode Exit fullscreen mode

Te lo digo, tienes que ver ese video. 😎

Convertir de UTF-8 a String es igual de fácil. Esta vez usa el método de decodificación:

List<int> list = [72, 101, 108, 108, 111, 240, 159, 152, 142];
String decoded = utf8.decode(list);
print(decoded); // Hello😎
Enter fullscreen mode Exit fullscreen mode

Está bien pasar una lista de valores int, pero tendrás una excepción si alguno de ellos es mayor de 8 bits así que ten cuidado con eso.

Lógica booleana

Asumo que sabes sobre los operadores &&, || y ! de lógica booleana que usas con el tipo bool:

// AND
print(true && true);   // true
print(true && false);  // false
print(false && false); // false
// OR
print(true || true);   // true
print(true || false);  // true
print(false || false); // false
// NOT
print(!true);          // false
print(!false);         // true
Enter fullscreen mode Exit fullscreen mode

Bueno, también hay equivalentes bitwise de estos operadores que trabajan con números binarios. Si piensas en false como 0 y true como 1, obtienes resultados similares con los operadores bitwise &, |, y ~ (más un operador adicional ^ XOR):

// AND
print(1 & 1);  // 1
print(1 & 0);  // 0
print(0 & 0);  // 0
// OR
print(1 | 1);  // 1
print(1 | 0);  // 1
print(0 | 0);  // 0
// XOR
print(1 ^ 1);  // 0
print(1 ^ 0);  // 1
print(0 ^ 0);  // 0
// NOT
print(~1);     // -2
print(~0);     // -1
Enter fullscreen mode Exit fullscreen mode

Bueno, los resultados son casi los mismos. El operador ~ bitwise NOT dio unos números extraños. Volveremos a eso en un rato, aunque probablemente ya hayas adivinado la razón.

Si quieres saber un poco más sobre las operaciones de bitwise antes de que veamos algunos ejemplos de Dart, echa un vistazo a esta serie de vídeos:

Bitwise AND operador &

El operador bitwise & compara cada par de bits y da un 1 en el bit resultante sólo si ambos de la entrada bits fueran 1.

Aquí hay un ejemplo:

final y = 0x4a;   // 01001010
final x = 0x0f;   // 00001111
final z = x & y;  // 00001010
Enter fullscreen mode Exit fullscreen mode

¿Por qué es eso útil? Bueno, una cosa útil es hacer un AND bitmask. Un bitmask es una forma de filtrar todo menos la información que estás buscando.

Puedes verlo en la práctica en el código fuente de Flutter TextPainter:

static bool _isUtf16Surrogate(int value) {
  return value & 0xF800 == 0xD800;
}
Enter fullscreen mode Exit fullscreen mode

Este método estático comprueba un valor UTF-16 para ver si es un surrogate pair. Los surrogates están en el rango 0xD800-0xDFFF. El valor 0xF800 es el bitmask.

Es más fácil de entender cuando lo ves en booleano.

D800  1101 1000 0000 0000  (min)
DFFF  1101 1111 1111 1111  (max)
F800  1111 1000 0000 0000  (bitmask)
Enter fullscreen mode Exit fullscreen mode

Notarán que los primeros cinco bits de cualquier sustituto son 11011. Los siguientes once bits pueden ser cualquier cosa. Así que ahí es donde entra la bitmask. Utiliza cinco bits 1 para coincidir con el patrón que está buscando y once 0 bits para ignorar el resto.

Recordemos que nuestro amigo Smiley 😎 está formado por los surrogate pairs D83D y DE0E, por lo que cualquiera de los dos valores debería volverse true. Probemos el segundo, DE0E:

DE0E  1101 1110 0000 1110  (test value)
F800  1111 1000 0000 0000  (bitmask)
-------------------------
D800  1101 1000 0000 0000  (result of & operation)
Enter fullscreen mode Exit fullscreen mode

El resultado es D800, así que eso debería hacer que la comparación sea cierta, lo que significa que sí, el DE0E es un surrogate pair. Puedes probarlo tú mismo:

bool _isUtf16Surrogate(int value) {
  return value & 0xF800 == 0xD800;
}

print(_isUtf16Surrogate(0xDE0E)); // true
Enter fullscreen mode Exit fullscreen mode

Bitwise OR operator |

El operador bitwise | compara cada par de bits y tiene un 1 en el bit resultante si alguno (o ambos) de los input bits fueran 1.

He aquí un ejemplo:

final y = 0x4a;   // 01001010
final x = 0x0f;   // 00001111
final z = x | y;  // 01001111
Enter fullscreen mode Exit fullscreen mode

¿Por qué es eso útil? Bueno, también puedes usarlo como un bitmask "turn on" cierto bits mientras dejas otros bits sin afectar.

Por ejemplo, los colores a menudo se empaquetan en un solo entero de 32-bit, donde los primeros 8 bits representan el valor alfa (transparencia), los siguientes 8 bits son el valor rojo, los siguientes 8 bits el valor verde, y los últimos 8 bits el valor azul.

Así que digamos que tienes este color purple semitransparente aquí:

Si quieres mantener el color pero hacerlo completamente opaco, podrías usar un OR bitmask para hacerlo. El valor hex para ese color es 802E028A:

alpha    red      green    blue
-----------------------------------
80       2E       02       8A
10000000 00101110 00000010 10001010
Enter fullscreen mode Exit fullscreen mode

Completamente transparente es 0x00 y completamente opaco es 0xFF. Así que quieres hacer un bitmask que mantenga los valores de rojo, verde y azul iguales, pero que haga el alfa 0xFF. En este caso debemos esperar FF2E028A para el resultado después de que se aplique el bitmask.

10000000 00101110 00000010 10001010  (original)
11111111 00000000 00000000 00000000  (bitmask)
-----------------------------------
11111111 00101110 00000010 10001010  (result of | operation)
Enter fullscreen mode Exit fullscreen mode

Como ORing cualquier cosa con 1 hace 1, esto convirtió todos los valores alfa en 1. ORing cualquier cosa con 0 no cambia el valor, así que los otros valores se mantuvieron iguales.

Inténtalo en Dart:

final original = 0x802E028A;
final bitmask = 0xFF000000;
final opaque = original | bitmask;
print(opaque.toRadixString(16)); // ff2e028a
Enter fullscreen mode Exit fullscreen mode

¡Ya lo tengo!

Nota: Si te molesta que la cadena hex esté en lowercase, siempre puedes encubrirla a uppercase:

Nota: Si le molesta que la cadena hex esté en lowercase, siempre puede encubrirla a uppercase:

print('ff2e028a'.toUpperCase()); // FF2E028A
Enter fullscreen mode Exit fullscreen mode

Bitwise XOR operador ^

El operador bitwise ^ compara cada par de bits y tiene un 1 en el bit resultante si la entrada bits es diferente.

Aquí hay un ejemplo:

final y = 0x4a;   // 01001010
final x = 0x0f;   // 00001111
final z = x ^ y;  // 01000101
Enter fullscreen mode Exit fullscreen mode

¿Por qué es eso útil? En Dart verás a menudo el operador ^ usado para crear códigos hash. Por ejemplo, aquí hay una clase de persona:

class Person {
  final String name;
  final int age;
  Person(this.name, this.age);

  @override
  bool operator ==(Object other) {
    return identical(this, other) ||
        other is Person &&
            runtimeType == other.runtimeType &&
            name == other.name &&
            age == other.age;
  }

  @override
  int get hashCode => name.hashCode ^ age.hashCode;
}
Enter fullscreen mode Exit fullscreen mode

La última línea con hashCode es la parte interesante. Reproduzcamos eso aquí:

final name = 'Bob';
final age = 97;
final hashCode = name.hashCode ^ age.hashCode;

print(name.hashCode); // 124362681
print(age.hashCode);  // 97
print(hashCode);      // 124362712
print(name.hashCode.toRadixString(2).padLeft(32, '0'));
print(age.hashCode.toRadixString(2).padLeft(32, '0'));
print(hashCode.toRadixString(2).padLeft(32, '0'));
Enter fullscreen mode Exit fullscreen mode

Aquí están los resultados binarios:

00000111011010011001111110111001  (name hash code)
00000000000000000000000001100001  (age hash code)
--------------------------------
00000111011010011001111111011000  (result of ^ operation)
Enter fullscreen mode Exit fullscreen mode

Para los códigos hash quieres que estén lo más distribuidos posible para que no se produzcan colisiones hash. Si haces operaciones &, obtienes más 0s, y si haces operaciones |, obtienes más 1s. Como el operador ^ mantiene la distribución de 0s y 1s, es un buen candidato para crear nuevos códigos hash a partir de otros.

El operador NOT Bitwise ~

El operador ~ invierte el valor de los bits. Antes viste estos resultados confusos:

// NOT
print(~1);     // -2
print(~0);     // -1
Enter fullscreen mode Exit fullscreen mode

Esto tiene más sentido si miras el valor binario. Aquí está la representación 64-bit de 1:

0000000000000000000000000000000000000000000000000000000000000001
Enter fullscreen mode Exit fullscreen mode

Cuando volteas todos esos bits (es decir, el resultado de ~1), obtienes:

1111111111111111111111111111111111111111111111111111111111111110
Enter fullscreen mode Exit fullscreen mode

Y si miran el video sobre Two's Complement, recordarán que así es como se expresa el número -2 en binario. Es una historia similar para ~0. Problema resuelto.

Por cierto, aquí hay un chiste para aquellos de ustedes que les gusta Shakespeare:

2b|~2b
Enter fullscreen mode Exit fullscreen mode

Esa es la cuestión.

Bit shifting << >>

Hay un último tema a tratar: bit shifting. Puedes cambiar bits a la izquierda con el operador bitwise left shift << y a la derecha con el operador bitwise right shift >>.

Puede aprender más sobre el bit shifting en este video:

https://youtu.be/mjqswwqE1RQ

Aquí hay un ejemplo en Dart de un shift izquierdo:

final value = 0x01;  // 00000001
print(value << 5);  // 00100000
Enter fullscreen mode Exit fullscreen mode

Todos los bits se desplazaron a la izquierda en 5. Un valor binario de 00100000 es 32. Como dato interesante, una forma sencilla de multiplicar por dos es hacer shift left por uno:

print(7 << 1);  // 14 (decimal)
Enter fullscreen mode Exit fullscreen mode

Aquí hay un right shift:

final value = 0x80;  // 10000000
print(value >> 3);  // 00010000
Enter fullscreen mode Exit fullscreen mode

Esto equivale a decir que 128 right shift 3 es 16.

¿Por qué es esto importante? Bueno, un lugar donde puedes verlo en Dart es para extraer valores de un integer empaquetado. Por ejemplo, el código fuente de la clase Color tiene el siguiente getter para extraer el valor rojo de un valor de color:

/// The red channel of this color in an 8 bit value.
int get red => (0x00ff0000 & value) >> 16;
Enter fullscreen mode Exit fullscreen mode

Esto primero utiliza un AND bitmask para encontrar sólo la parte red del valor ARGB (alpha red green blue) y luego mueve el resultado al principio. Así es como se vería con nuestro 802E028A color purple de antes.

alpha    red      green    blue
-----------------------------------
80       2E       02       8A
10000000 00101110 00000010 10001010  (original)
00000000 11111111 00000000 00000000  (bitmask)
-----------------------------------
00000000 00101110 00000000 00000000  (result of &)
-----------------------------------
00000000 00000000 00000000 00101110  (result of >> 16)
Enter fullscreen mode Exit fullscreen mode

Y aquí está en Dart:

final purple =  0x802E028A;
final redBitmask = 0x00FF0000;
final masked = purple & redBitmask;
final redPart = masked >> 16;
print(redPart.toRadixString(16)); // 2e
Enter fullscreen mode Exit fullscreen mode

Puntos clave

¡Lo lograste! Aquí están los puntos clave de este artículo:

  • Los datos binarios pueden ser expresados como una lista de números enteros.
  • Uint8List es una lista de 8-bit de números enteros sin signo y es más eficiente que List<int> cuando se trabaja con grandes cantidades de datos binarios.
  • Se puede utilizar BytesBuilder para añadir datos binarios a una lista.
  • Los datos de los bytes están respaldados por un BytesBuffer y se pueden obtener diferentes vistas de los bytes subyacentes. Por ejemplo, se pueden ver los bytes en los segmentos 8-bit, 16-bit, 32-bit, o 64-bit, ya sea como enteros son o sin signo.
  • Con los trozos mayores de 8 bits hay que tener en cuenta endianness, que se ve afectado por la máquina o el formato de almacenamiento subyacente. Se puede utilizar ByteData para especificar ya sea vistas big endian o little endian.
  • Prefije un número con 0x para escribirlo en notación hexadecimal.
  • Dart strings son listas de valores UTF-16 y se conocen como unidades de código.
  • Convierte strings a UTF-8 utilizando la biblioteca dart:convert.
  • Los operadores lógicos bitwise son &, |, ^, y ~, y los operadores shift son << y >>.
  • Los operadores y las máscaras de bits permiten acceder y manipular los bits individuales.

Continuando

Un par de áreas que merecen un estudio más profundo son los streams de bytes y cómo transformar los datos de esos streams. Por ejemplo, se puede obtener un byte stream al leer o descargar un archivo. En lugar de esperar a que llegue todo el archivo antes de trabajar con él, puedes empezar a transformar los valores bytes a UTF-8 (o algo más) a medida que los obtengas.

Recuerda dejar un comentario o hacer una pregunta si algo no está claro.

💖 💪 🙅 🚩
maginkgo
Marcos Alejandro

Posted on January 13, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related