Unicode en Dart: Runes y la realidad de los strings

En Dart, un String no es una secuencia de caracteres, sino una secuencia de unidades de código UTF-16. Esto es fundamental entenderlo porque la longitud de un String (length) devuelve el número de unidades de 16 bits almacenadas en memoria, no la cantidad de símbolos que un usuario ve en pantalla. Para navegar la realidad de Unicode, debemos distinguir tres capas: las code units (el almacenamiento físico en UTF-16), los code points (el valor numérico único asignado por el estándar Unicode) y los grapheme clusters (lo que percibimos visualmente como un solo carácter).

Si intentas manipular un String basándote únicamente en sus índices de length, corres el riesgo de “partir” un carácter a la mitad, especialmente cuando trabajas con emojis o caracteres especiales que utilizan surrogate pairs (dos unidades de 16 bits que representan un solo code point). Para iterar sobre los valores Unicode reales, Dart expone la propiedad runes, que es un Iterable<int> de code points.

No obstante, la API nativa de String se centra en la eficiencia de la VM. Si tu lógica de negocio requiere contar caracteres “reales” (por ejemplo, para validar la longitud de un nombre de usuario que incluya emojis o acentos combinados), la API estándar de String te dará valores inconsistentes. Para manejar grapheme clusters con precisión, es obligatorio recurrir al paquete characters.

void main() {
  // Un emoji de alta gama (U+1F600) que requiere un surrogate pair en UTF-16
  const String emoji = '😀';
  
  // Un carácter con acento mediante combinación (e + \u{0301})
  // Esto son dos code points distintos que se renderizan como un solo símbolo.
  const String combinedAccent = 'e\u{0301}';

  print('--- Análisis de dimensiones ---');
  // El emoji tiene 2 unidades UTF-16 pero es 1 único Rune (code point).
  print('Emoji: length=${emoji.length}, runes=${emoji.runes.length}');
  
  // El acento combinado tiene 2 unidades y 2 runes, aunque visualmente es 1 carácter.
  print('Acento: length=${combinedAccent.length}, runes=${combinedAccent.runes.length}');

  print('\n--- Inspección de valores ---');
  // Acceder a la primera unidad de memoria del emoji (High Surrogate)
  print('Emoji - primera codeUnit: ${emoji.codeUnitAt(0)}');
  // Acceder al código Unicode real del emoji
  print('Emoji - primer rune: ${emoji.runes.first}');

  print('\n--- Construcción desde Code Points ---');
  // Podemos reconstruir un string a partir de una lista de enteros Unicode
  final codePoints = [0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0x1F600]; // "Hello 😀"
  final reconstructed = String.fromCharCodes(codePoints);
  print('Reconstruido: $reconstructed');

  // Un solo carácter a partir de un código hexadecimal
  final singleChar = String.fromCharCode(0x1F600);
  print('Char único: $singleChar');

  print('\n--- El peligro de los índices ---');
  // Si cortamos un string justo en medio de un surrogate pair...
  final broken = emoji.substring(0, 1);
  print('Substring roto: $broken (esto es un carácter inválido)');
}

Desglose del ejemplo

En el código anterior, observa cómo emoji.length devuelve 2 mientras que emoji.runes.length devuelve 1. Esto sucede porque la VM de Dart almacena el emoji 😀 como dos unidades de 16 bits para poder representarlo en un espacio de memoria eficiente. Si usas codeUnitAt(0), estarás obteniendo el valor de la primera mitad del par (el high surrogate), un valor que por sí solo no representa un carácter comprensible.

Cuando trabajamos con combinedAccent (e + \u{0301}), la situación es distinta: aquí tenemos dos code points válidos y distintos, pero el renderizado del sistema operativo los une visualmente. Por eso, length y runes.length coinciden en 2. Si tu aplicación debe validar que un input no exceda los 5 caracteres, el usuario podría escribir un solo emoji y tu validación de length podría decir que el string mide 2.

Al usar String.fromCharCodes, estamos pasando directamente de la lógica Unicode (el valor entero del code point) al almacenamiento UTF-16. El motor de Dart se encarga de realizar la conversión a los pares de sustitución necesarios.

El método substring(0, 1) sobre el emoji demuestra por qué es peligroso manipular strings por índices cuando hay caracteres fuera del plano básico (BMP). Al extraer solo la primera mitad del par de unidades, generamos una secuencia de bits que no apunta a un carácter Unicode válido, lo que resulta en el carácter de reemplazo o comportamientos impredecibles según el motor de renderizado.

El error frecuente

El error más común ocurre al intentar truncar o procesar strings basándose en String.length cuando el input es dinámico y puede contener emojis o caracteres combinados.

void badPractice(String input) {
  // Intentar obtener los primeros 2 "caracteres" de un emoji
  // Si el emoji es '😀' (length 2), substring(0, 2) funciona, 
  // pero substring(0, 1) rompe el emoji.
  print('Corte peligroso: ${input.substring(0, 1)}');
}

void main() {
  badPractice('😀'); 
  // Output: Corte peligroso: �
}

Si necesitas realizar operaciones de edición de texto (como un editor de notas) o validaciones de longitud que el usuario perciba como “correctas”, utiliza siempre el paquete characters para iterar sobre grapheme clusters en lugar de String.runes o String.length.

41

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio