Manipulación de strings en Dart hecho bien 👉

¿Tu aplicación usa emojis o soporta múltiples idiomas? Dart acaba de introducir una nueva forma de manejarlos de manera segura en la manipulación de strings.

Al igual que muchos otros lenguajes de programación diseñados antes de que los emojis empezaran a dominar nuestras comunicaciones diarias y el surgimiento del soporte multilingüe en aplicaciones comerciales, Dart representa un string como una secuencia de unidades de código UTF-16. La codificación funcionaba bien en la mayoría de los casos, hasta que el aumento de la internacionalización y la introducción de emojis que acompañan a cualquier idioma hicieron que los problemas inherentes de la codificación se convirtieran en problemas de todos.

Considera este ejemplo:

Diagrama que muestra cómo el string 'Hello 👋' se mapea a unidades de código UTF-16.

En el string “Hello👋”, cada carácter perceptible por el usuario se mapea a una sola unidad de código excepto el emoji de la mano saludando 👋. Una consecuencia inmediata de este mapeo es la confusión sobre la longitud de este string. ¿Será la salida de la siguiente línea de código 6 o 7?

print('Hello👋'.length);

Para el usuario, hay claramente 6 caracteres en este string a menos que te pongas filosófico. Pero la API de String de Dart te dirá que el length es 7, o para ser precisos, 7 unidades de código UTF-16. Esta diferencia tiene todo tipo de implicaciones, porque muchas tareas de manipulación de texto implican usar índices de caracteres con la String API. Por ejemplo, "Hello👋"[5] no devolverá el emoji 👋. En su lugar, devolverá un carácter malformado que representa la primera unidad de código del emoji.

La buena noticia es que Dart tiene un nuevo paquete llamado characters que opera sobre caracteres perceptibles por el usuario en lugar de unidades de código UTF-16. Sin embargo, tú, como programador de Dart, necesitas saber cuándo usar el paquete characters. Nuestra investigación indica que incluso los programadores experimentados de Dart pueden pasar por alto fácilmente estos problemas al leer código de manipulación de texto. En este artículo, repaso algunos escenarios comunes donde necesitas prestar atención extra y considerar usar el paquete characters en lugar de String de Dart.

Escenarios a vigilar

#

En esta sección, repasaré algunos escenarios comunes de manipulación de texto, explicaré por qué usar la API de String de Dart podría causar problemas en estos escenarios, y mostraré cómo usar el paquete characters para obtener resultados más confiables. Los casos de uso a continuación generalmente asumen que estamos tratando con strings ingresados por usuarios humanos, que podrían incluir emojis o caracteres en un idioma que el desarrollador de la app no espera.

Escenario 1: Contar caracteres en un string

#

Supongamos que estás escribiendo una función que verifica si el texto ingresado por el usuario ha excedido un número específico de caracteres. La función devuelve un número positivo de caracteres restantes si el límite no se ha alcanzado, o un número negativo de caracteres adicionales si el límite se ha excedido.

Esto es bastante sencillo de hacer usando la API de String:

dart
// Implementation using the String API,
// which counts the number of UTF-16 code units
// instead of user-perceivable characters.
int remainingCapacity(String input, int limit) {
  var length = input.length;
  return limit - length;
}

Sin embargo, la siguiente prueba revela el problema con este código:

dart
test('remainingCapacity', (){
  var limit = 140;
  input = 'Laughter 😀 is the sensation of feeling good all over and showing it principally in one place.';
  expect(remainingCapacity(input, limit), equals(47));
});

Aquí están los resultados de las pruebas:

Expected: <47>
  Actual: <46>

Podemos reescribir esta función usando el paquete characters, que proporciona un método de extensión conveniente en String, para producir el número correcto de caracteres de la siguiente manera:

dart
int checkMaxLength(String input, int limit) {
  var length = input.characters.length;
  return limit - length;
}

Escenario 2: Extraer un substring

#

En este escenario, queremos implementar una función que elimine el último carácter de un string y devuelva el resultado como un nuevo string. Supongamos que este string proviene de entrada del usuario.

Esta función es fácil de implementar usando el método substring en String de la siguiente manera:

dart
String skipLastChar(String text) {
  return text.substring(0, max(0, text.length - 1));
}

Sin embargo, una buena prueba con emojis puede romper rápidamente el código:

dart
test('skipLastChar(text)', () {
  var string = 'Hi 🇩🇰';
  expect(skipLastChar(string), equals('Hi '));
});

Aquí están los resultados de las pruebas:

Expected: ‘Hi ’
  Actual: ‘Hi 🇩???’
    Which: is different. Both strings start the same, but the actual value also has the following trailing characters: 🇩???

El paquete characters puede manejar este caso con facilidad, ya que proporciona métodos de alto nivel como skipLast(int count). Podemos reescribir este fragmento en el siguiente código:

dart
String skipLastChar(String text) {
  return text.characters.skipLast(1).toString();
}

Escenario 3: Dividir un string por un emoji

#

En el tercer escenario, queremos dividir un string por un emoji dado. Aquí hay una función que hace eso usando el método split en String:

dart
List splitEmojiSeparatedWords(String text, String separator) {
  return text.split(separator);
}

¿Funcionará? Probablemente funcione bien el 99% de las veces, pero la prueba siguiente ilustra un ejemplo donde el código anterior produce resultados bastante sorprendentes.

dart
test('splitEmojiSeparatedWords(String text, String separator)', () {
   var text = 'abc👨‍👩‍👧‍👦👧abc👧abc👧abc';
   var separator = '👧';
   List<String> expected = ['abc👨‍👩‍👧‍👦', 'abc', 'abc', 'abc'];
   expect(td.splitEmojiSeparatedWords(text, separator), equals(expected));
});

Aquí están los resultados de las pruebas:

Expected: ['abc👨‍👩‍👧‍👦', 'abc', 'abc', 'abc']
  Actual: ['abc👨‍👩‍','‍👦', 'abc', 'abc', 'abc']
    Which: was 'abc👨‍👩‍' instead of 'abc👨‍👩‍👧‍👦' at location [0]

Entonces, ¿por qué 👨‍👩‍👧‍👦 se convirtió en dos emojis 👨‍👩 cuando el string fue dividido? Es porque 👨‍👩‍👧‍👦 está realmente compuesto por cuatro emojis diferentes: 👨👩👧👦. Cuando el string se dividió por 👧, “abc👨‍👩‍👧‍👦” se separó en dos partes: “abc👨‍👩” y “‍👦”.

Puedes evitar este problema usando el método split en la clase Characters, como muestra el siguiente código:

dart
List<String> splitEmojiSeparatedWords(String text, String separator) {
  // Split returns an iterable, which we need to convert to a list.
  return [...text.characters.split(separator.characters)];
}

Escenario 4: Acceder a un carácter específico por su índice

#

En la manipulación de texto, es común acceder a un carácter específico por su índice (es decir, posición) en el string. Por ejemplo, el fragmento muestra una función que devuelve las iniciales del nombre y apellido ingresados por el usuario en dos campos de texto separados:

dart
String createInitials(String firstName, String lastName) {
    return firstName[0].toUpperCase() + lastName[0].toUpperCase();
}

Pero como hemos demostrado al comienzo del artículo, usar el índice en un string basado en UTF-16 puede ser arriesgado. Verifiquemos la corrección del código anterior con el caso de prueba siguiente:

dart
test("createInitials(firstName, lastname)", () {
    var firstName = 'étienne';
    var lastname = 'bézout';
    expect(td.createInitials(firstName, lastname), equals('ÉB'));
});

Aquí están los resultados de las pruebas:

Expected: ‘ÉB’
  Actual: ‘EB’
    Which: is different.

¿Por qué falló la prueba? Es porque la letra "É" podría ser una combinación de "E" y la marca de acento. Puedes usar el characters para evitar fácilmente este problema:

dart
String createInitials(String firstName, String lastName) {
  return '${firstName.characters.first}${lastName.characters.first}';
}

Ejercicio: Omitir desbordamiento de texto

#

Ahora, aquí hay un desafío para ti. En este escenario, la app necesita mostrar una lista de mensajes, uno por línea. Se te pide que revises código que implementa una función que muestra el desbordamiento de texto como puntos suspensivos cuando la longitud del mensaje excede el límite de caracteres dado.

dart
String textOverflowEllipsis(String text, int limit) {
  if (text.length > limit) {
    return text.substring(0, limit - 3) + '';
  } else {
    return text;
  }
}

¿Puedes crear una prueba que revele un problema potencial con este fragmento de código? ¿Cómo lo reescribirías usando el paquete characters? La respuesta está al final de este artículo.

Mitigaciones y posible solución a largo plazo

#

Es irrazonable esperar que los usuarios de Dart se mantengan en alerta máxima ante los tipos de trampas descritos anteriormente. Por ejemplo, en un experimento que realizamos, el 53.7% de los usuarios de Dart no pudieron detectar el problema ilustrado en el primer escenario (contar caracteres), a pesar de que recibieron dos páginas de información sobre el paquete characters y el problema que el paquete fue diseñado para resolver apenas unos minutos antes. Por lo tanto, estamos tomando un enfoque en dos etapas para ayudar a los desarrolladores a elegir la API más apropiada para sus necesidades de manipulación de texto.

A corto plazo, estamos introduciendo un conjunto de mitigaciones en el framework de Flutter y el analyzer de Dart para hacer que el paquete characters sea más fácil de descubrir e invocar en la programación de UI en Dart. Esto involucra algunos pasos:

  1. Usar el paquete characters en la implementación interna del widget TextField. Consulta este PR y este documento de diseño para más detalles.

  2. Exponer la API del paquete characters a través del framework de Flutter. Una vez que se haga esto, los usuarios de Flutter tendrán una mayor probabilidad de descubrir la API a través del método de extensión String.characters, que aparecerá al hacer un autocompletado en String. El estado de este trabajo se sigue en este issue: https://github.com/flutter/flutter/issues/55593.

  3. Actualizar la documentación de la API y el código de ejemplo del framework de Flutter para sugerir el uso de la clase Characters cuando sea aplicable, como en el callback para TextField.onChanged. Este trabajo se sigue en https://github.com/flutter/flutter/issues/55598 con detalles relevantes en este documento.

  4. Hacer que el analyzer de Dart sugiera convertir un objeto String a un objeto Characters al autocompletar una plantilla de callback para manejar texto ingresado por el usuario. Por ejemplo, el IDE podría completar todo en el fragmento siguiente después de que el usuario haga autocompletado en onChanged. Este trabajo se sigue en https://github.com/dart-lang/sdk/issues/41677.

dart
TextField(
  onChanged: (String value) {
  // Converting String to Characters to handle emojis
  // and non-English characters more robustly.
  var myText = value.characters;
  }
)

Esas mitigaciones pueden ayudar, pero se limitan a manipulaciones de strings realizadas en el contexto de un proyecto de Flutter. Necesitamos medir cuidadosamente su efectividad después de que estén disponibles. Una solución más completa a nivel del lenguaje Dart probablemente requerirá la migración de al menos parte del código existente, aunque algunas opciones (por ejemplo, tipos de extensión estáticos) podrían hacer que los cambios disruptivos sean manejables. Se necesita más investigación técnica para entender completamente las compensaciones.

Cómo puedes ayudar

#

Por favor, ayúdanos a crear conciencia sobre cómo solucionar problemas de strings usando el paquete characters:

  • Busca instancias del uso de String.length o String.substring en tu propio código. Si el string podría haberse originado a partir de entrada del usuario, intenta reescribir el código usando el paquete characters.

  • Comparte este post con otros en la comunidad de Dart.

  • Intenta actualizar respuestas existentes sobre manipulación de texto en Dart en StackOverflow. Si las respuestas aceptadas pasaron por alto esta limitación de la API de String, recuérdale a la gente el riesgo.

  • Comenta en los issues de GitHub listados anteriormente para hacernos saber tus pensamientos y opiniones.

¡Ahora, feliz programación 😉!

Agradecimientos

#

Gracias a Kathy Walrath, Lasse Nielsen y Michael Thomson por revisar este artículo. También me gustaría agradecer a los desarrolladores que participaron en nuestra investigación de usuarios. Su participación ayudó a los equipos de Dart y Flutter a entender mejor el desafío de lidiar con esta limitación de la API de String de Dart.

— — —

PD: Aquí está la solución para el ejercicio:

dart
// Prerequisite: add the characters package as a dependency in your pubspec.yaml.
import 'package:characters/characters.dart';

void main(List<String> arguments) {
  print(textOverflowEllipsis('😸cats', 10));
  print(textOverflowEllipsis('🦏rhinoceroses', 10));
}

// This function converts text overflow to an ellipsis
// when the text's length exceeds the given character limit.
String textOverflowEllipsis(String text, int limit) {
  var myChars = text.characters;
  if (myChars.length > limit) {
    return '${myChars.take(limit - 1)}';
  } else {
    return text;
  }
}

Más de Dart