Trabajando con bytes en Dart
Marcos Alejandro
Posted on January 13, 2021
Si puedes entender los bytes, puedes entender cualquier cosa.
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
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];
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
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
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
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.
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';
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);
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]
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]
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);
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]
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;
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]
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);
...obtendrías la siguiente excepción:
Unsupported operation: Cannot add to a fixed-length list
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]);
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]
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
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
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
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
O los valores 64-bit
:
0011111100011101110011011101101110011111100110011011110000010101
0011101000101010100001111001011011110011110111000001010110011011
1101010110100001110110010100011110000010010001111110101111111010
0111010011001100000000000111111010010010000000001111010001100000
O incluso valores de 128-bit
:
00111111000111011100110111011011100111111001100110111100000101010011101000101010100001111001011011110011110111000001010110011011
11010101101000011101100101000111100000100100011111101011111110100111010011001100000000000111111010010010000000001111010001100000
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
En forma decimal la lista se vería así:
1, 0, 0, 128
Primero crea la lista en Dart como lo has hecho anteriormente:
Uint8List bytes = Uint8List.fromList([1, 0, 0, 128]);
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;
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();
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);
En mi ordenador Mac los resultados son los siguientes:
[1, 32768]
Eso es muy extraño. Ya que los valores originales eran:
00000001 00000000 00000000 10000000
1 0 0 128
Hubiera esperado que se combinaran en trozos de 16-bit
como este:
0000000100000000 0000000010000000
256 128
En cambio, tenemos esto:
0000000000000001 1000000000000000
1 32768
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
Ahora ejecuta el siguiente código:
Uint8List bytes = Uint8List.fromList([0, 1, 2, 3]);
ByteBuffer byteBuffer = bytes.buffer;
Uint32List thirtytwoBitList = byteBuffer.asUint32List();
print(thirtytwoBitList);
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
o si se añade un espacio (para ayudar a ver las partes):
00000011 00000010 00000001 00000000
¡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):
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);
Dart tomó la lista de estos cuatro bytes:
00000000 00000001 00000010 00000011
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
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);
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;
}
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
mientras que las máquinas big endian las ordenan así:
00000000 00000001
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);
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 parabyteList
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
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
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);
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]
¿Tiene sentido? Anteriormente el buffer contenía:
[0, 1, 2, 4]
Pero reemplazaste los dos últimos bytes con 256, que en binario es:
00000001 00000000
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;
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
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
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
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
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
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
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)
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:
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]
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 tipoint
se supone que es genérico y no debes pensar en cuántos bytes utiliza. Internamente, strings son listas de entenros de16-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
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
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
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:
- Trabajando con Unicode y los clusters de Grapheme en Dart
- Dart manipulación de cadenas hecha correctamente 👉
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';
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]
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
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😎
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
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
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 Operators 1: The AND Operation
- Bitwise Operators 2: The OR Operation
- Bitwise Operators 3: The XOR Operation
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
¿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;
}
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)
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)
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
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
¿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
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)
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
¡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
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
¿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;
}
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'));
Aquí están los resultados binarios:
00000111011010011001111110111001 (name hash code)
00000000000000000000000001100001 (age hash code)
--------------------------------
00000111011010011001111111011000 (result of ^ operation)
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
Esto tiene más sentido si miras el valor binario. Aquí está la representación 64-bit
de 1:
0000000000000000000000000000000000000000000000000000000000000001
Cuando volteas todos esos bits (es decir, el resultado de ~1), obtienes:
1111111111111111111111111111111111111111111111111111111111111110
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
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:
Aquí hay un ejemplo en Dart de un shift izquierdo:
final value = 0x01; // 00000001
print(value << 5); // 00100000
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)
Aquí hay un right shift:
final value = 0x80; // 10000000
print(value >> 3); // 00010000
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;
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)
Y aquí está en Dart:
final purple = 0x802E028A;
final redBitmask = 0x00FF0000;
final masked = purple & redBitmask;
final redPart = masked >> 16;
print(redPart.toRadixString(16)); // 2e
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 de8-bit
de números enteros sin signo y es más eficiente queList<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 segmentos8-bit
,16-bit
,32-bit
, o64-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.
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
November 30, 2024