30 de Junio de 2010

En una ocasión una conocida empresa de transportes me envió un paquete, según el cual yo vivo en una ciudad llamada CORUÐA, lo cual es absurdo, porque según los anuncios dirigidos de Internet ¡yo vivo en Coruña! (o en Coru�a, según versiones). Por supuesto, esto también está mal. Estos son errores de codificación, y se deben a que el programador no tuvo en cuenta unas cuantas reglas básicas. En un tema previo ya escribí sobre codificaciones, ahora intentaré dar unos cuantos consejos básicos, para evitar al menos los errores más sangrantes.

Caracteres o bytes

La primera y más importante regla es distinguir en todo momento si una determinada variable contiene bytes o caracteres. La confusion entre ambos está perpetuada porque en ciertos lenguajes populares (véase C y derivados) el tipo char es en realidad un byte, por definición, aunque debe ser también capaz de almacenar un carácter del llamado conjunto básico de caracteres: la propia definición del lenguaje sugiere la equivalencia entre byte y carácter. Otros lenguajes que sí distingues entre ambos (Visual Basic y amigos, por ejemplo) definen una función llamada Asc (de ASCII), que devuelve el código (code point) de cualquier carácter, con lo que se sugiere cierta universalidad del código ASCII. Pero cuál es el código ASCII de la letra 'Ñ' es una pregunta sin sentido.

Lenguajes con Unicode

En un lenguaje de programación moderno, con soporte nativo Unicode, como Java, C# o Python 3, el tipo char es lo bastante grande para representar un code point Unicode en la codificación interna (normalmente UTF-16 o UTF-32), o sea, 16 o 32 bits. Estos lenguajes suelen incluir también el tipo byte, que representa un número entre 0 y 255, es decir, un byte como número. En general estos dos tipos, char y byte, no son intercambiables ni convertibles directamente entre sí. Y un string no es más que un array de caracteres. Dado este entorno es difícil hacerlo mal, los caracteres y los bytes simplemente no se mezclan, y las codificaciones de caracteres son irrelevantes... hasta que dejan de serlo. En general, los sistemas de almacenamiento (ficheros o similares), las conexiones de red o los periféricos trabajan con bytes, no con caracteres Unicode. En estos casos es necesario convertir la secuencia de caracteres en una secuencia de bytes, y entonces es esencial ser consciente de la codificación de caracteres utilizada. En este contexto una codificación puede verse como una caja negra que convierte secuencias de caracteres en secuencias de bytes y viceversa. Qué codificación es la correcta es una cuestión que debe analizarse en cada caso particular. Si estás diseñando el sistema y puedes escoger, mi recomendación es usar siempre UTF-8, un mínimo de problemas a un coste razonable. Si tienes que relacionarte con sistemas pre-existentes, entonces estos deberían documentar las codificaciones utilizadas, si tienes suerte puede que incluso puedas escogerla (hazme caso: pide UTF-8 si puedes). En la vida real, claro está, la documentación no siempre es muy completa, así que puede que tengas que experimentar un poco para descubrir la codificación correcta.

Debes ser consciente de que la codificación por defecto (que suele haber una) puede no ser la correcta (o peor aún, puede que coincida con la correcta en tu PC de desarrollo, pero no en el de tu cliente).

Pero... espera un momento, en C# por ejemplo, ¿qué significa esto?:

StreamWriter w = new StreamWriter("file.txt");
w.Write("¡Me gusta La Coruña!"); //caracteres, no bytes!

El truco está en la clase StreamWriter, cuya función miembro Write() recibe un string y lo convierte a bytes utilizando una cierta codificación de caracteres (por defecto UTF-8, pero puede indicarse otra en el constructor). Los bytes así generados se guardan en el Stream indicado (por defecto un FileStream). De hecho el código anterior equivale a:

FileStream file = File.Create("file.txt");
StreamWriter w = new StreamWriter(file, Text::Encoding::UTF8);
w.Write("¡Me gusta La Coruña!");

O incluso:

FileStream file = File.Create("file.txt");
array<byte> bs = new UTF8Encoding()->GetBytes("¡Me gusta La Coruña!");
file.Write(bs); //aquí son bytes

El caso de Python es curioso porque el soporte Unicode se fue incluyendo de forma progresiva y compatible hacia atras... hasta Python 3, en el que se hizo una reforma completa e incompatible. En Python 2 no existen los tipos byte ni char, en su lugar se usan los tipos int (de 0 a 255) y str (con longitud 1). Cuando se necesita un array de bytes, simplemente se utiliza un valor de tipo str y se interpreta el contenido como secuencia de bytes, no de caracteres (las funciones chr() y ord() convierten el tipo). Esta costumbre hace muy difícil abstraer la codificación de caracteres del tipo str, por lo que se inventaron el tipo unicode para representar caracteres Unicode, y bytes para secuencias de bytes. Puede convertirse entre ellos con las funciones miembro encode() y decode(). Lo ingenioso es que en Python 2 el tipo bytes es un sinónimo de str mientras que unicode es un tipo distinto, mientras que en Python 3 el tipo str es en realidad el unicode antiguo y bytes es un tipo nuevo (el str antiguo deja de existir). En Python 3, por tanto todos los textos son siempre Unicode.

Lenguajes sin Unicode

Y ¿qué pasa con los lenguajes de programación antiguos, como C o C++? Para estos lenguajes no hay una solución óptima: el propio lenguaje asume que una cadena de texto es una secuencia de bytes, con lo que es responsabilidad del programador saber en qué codificación particular están representados esos caracteres en memoria. Los tipos para carácter y byte son en realidad el mismo, lo que hace sencilla la confusión: una codificación de caracteres convierte una secuencia de bytes en otra secuencia de bytes diferente, no existe la abstracción de caracteres. Los responsables del lenguaje C intentaron arreglar esto con una ampliación del lenguaje: el tipo wchar_t. Este tipo se define como un "carácter ancho", lo bastante como para contener cualquier carácter del "conjunto de caracteres extendido", pero no indica qué conjunto es ese, ni qué codificación utiliza, eso debe definirlo el fabricante del compilador. Naturalmente, toda la librería estándar del lenguaje (o casi) se extiende para trabajar alternativamente con char o con wchar_t. Por ejemplo:

wchar_t texto[] = L"Hola"; //el prefijo L indica wchar_t
size_t x = wcslen(texto); //wcs: wide char string
wprintf(L"%s %d\n", texto, (int)x);

Los problemas de este método son muchos y graves, por lo que los wchar_t no se usan demasiado en la práctica:

  • El lenguaje no especifica la codificación de wchar_t ni de char, lo cual hace difícil el código portable.
  • Las funciones que convierten entre wchar_t y char (mbsrtowcs() y similares) asumen las codificaciones por defecto.
  • Prácticamente todas las librerías existentes reciben y devuelven secuencias de char, no de wchar_t. Dar soporte a los caracteres anchos implicaría duplicar toda la API.
  • Si escribes un carácter no ASCII en el código fuente del programa, aun dentro de un texto ancho, L"ñ", la interpretación exacta del carácter depende de la codificación con la que se guarda el fichero fuente y la configuración del compilador.

En la práctica, cuando se programa en C o C++, la solución más habitual depende del sistema operativo utilizado.

En GNU/Linux, todos los textos se codifican por defecto en UTF-8 y se representan en memoria como arrays de char terminados en nulo. De esta manera las funciones estándar siguen funcionando igual. Ocurre entonces que algunas funciones cambian ligeramente de significado, por ejemplo strlen() devuelve el número de bytes, no de caracteres. Pero si lo piensas bien, en casi todos los casos son los bytes lo que queremos contar. Así todo sigue funcionando igual, excepto los análisis de textos, e incluso estos si restringimos los signos de puntuación a caracteres ASCII. Es sentido estricto, en Linux, las aplicaciones de consola toman por defecto la codificación especificada en el locale (variables de entorno LC_CTYPE o LANG). En la práctica, sin embargo, cualquier Linux con menos de unos 5 años utilizará solamente locales Unicode.

En MS Windows la situación es peculiar. La familia Windows 95 (95, 98 y ME) usaban codificaciones de 8 bits, heredadas del Win16 (Windows1252 por ejemplo). Pero en la familia de Windows NT (NT, 2000, XP, etc.) el kernel utiliza solamente textos en UTF-16. Para resolver la API de forma compatible, Microsoft se decidió por la solución "a lo grande": todas las funciones públicas que reciban o devuelvan texto existen en dos versiones, una utiliza UTF-16 (Microsoft la llama simplemente "Unicode") y la otra la codificación de 8 bits compatible ASCII que corresponda a la versión local de Windows (Microsof la llama "ANSI"). La función Unicode termina con la W, mientras que la ANSI termina con A. Luego, una floritura del precompilador permite crear código que compile tanto en modo Unicode como en modo ANSI. Por ejemplo:

#ifdef UNICODE
typedef wchar_t TCHAR;
#else
typedef char TCHAR;
#endif
typedef char *LPSTR;
typedef wchar_t *LPWSTR;
typedef TCHAR *LPTSTR;

int WINAPI GetWindowTextA(HWND,LPSTR,int);
int WINAPI GetWindowTextW(HWND,LPWSTR,int);
#ifdef UNICODE
#define GetWindowText GetWindowTextW
#else
#define GetWindowText GetWindowTextA
#endif
//int WINAPI GetWindowText(HWND,LPTSTR,int);

La función GetWindowText en realiad no existe, es una macro que se expande a GetWindowTextW o GetWindowTextA, según se compile con la macro UNICODE. Toda la API de Windows, que no es pequeña, tiene esta duplicidad. Casi nada. Y no solo las funciones, también las estructuras, los mensajes de ventanas y las callbacks. Todo esto hace que sea difícil programar Unicode en Windows, y más si se pretende que el código sea mínimamente portable. Mi recomendación personal es que se utilice UTF-8 internamente en todo el programa y se convierta de/a UTF-16 justo en las llamadas a la API.

Y para rizar el rizo, además de las codificaciones "Unicode" y "ANSI", los programas de consola en Windows utilizan una codificación diferente, a la que Microsoft, cómo no, llama con un nombre un tanto confuso: "OEM": por defecto corresponde a la que se utilizaba en la versión de MS-DOS del mismo idioma, o sea, en Europa Occidental es la CP850. Que las aplicaciones de consola usen una codificación diferente que las de ventanas es una fuente interminable de confusión, tanto para usuarios como para desarrolladores. Prueba lo siguiente en Windows y verás lo que quiero decir:

C:\> echo Coruña > f.txt
C:\> notepad f.txt
//corrige el carácter raro y guarda
C:\> type f.txt

Otros ficheros con codificación

La mayoría de los lenguajes de programación esperan sus ficheros fuente escritos en una codificación compatible con ASCII. Y ya con intención, las palabras reservadas y los signos de puntuación son siempre caracteres ASCII. De esta forma, la codificación particular utilizada solamente influye en la interpretación de los caracteres no ASCII. Existen varias formas de resolver estos:

  1. No hacer nada. Las secuencias de bytes que forman los caracteres no ASCII se mueven sin cambios a las cadenas de texto. Esto tiene sentido para lenguajes como C, que no tienen un tipo propio para los caracteres, pero no sirve para lenguajes 100% Unicode.
  2. El compilador/intérprete recibe un parámetro diciendo qué codificación debe utilizar. Por ejemplo, un navegador Web, al cargar una página HTML recibe la cabecera HTTP Encoding:.
  3. Hacia el principio del fichero se incluye una directiva indicando qué codificación se utiliza. Desde el principio del fichero hasta la directiva en cuestión solo se pueden utilizar caracteres ASCII. El ejemplo clásico es el de la declaración XML: <?xml version="1.0" encoding="UTF-8" ?> o el <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> de HTML.
  4. BOM. El BOM (Byte Order Mark) es el code point Unicode 0xFEFF. Este code point está prohibido en cualquier sitio excepto al principio del fichero, y se codifica de forma diferente y distintiva con cada codificación Unicode. En UTF-8: 0xEF, 0xBB, 0xBF; en UTF-16BE: 0xFE, 0xFF; en UTF-16LE: 0xFF, 0xFE; en UTF-32BE: 0x00, 0x00, 0xFE, 0xFF; en UTF-32LE: 0xFF, 0xFE, 0x00, 0x00. Esta es la solución favorita de Microsoft en .NET.
  5. No usar caracteres no ASCII y proporcionar una alternativa para indicar code points Unicode por número. Casi todos los lenguajes tienen esta opción.

Conclusión

Si alguna vez ves uno de tus programas cometer un error similar a los de la introducción es útil diagnosticar cuáles son exactamente las codificaciones confundidas. La técnica es sencilla: se van comprobando qué bytes se corresponden al carácter esperado y al recibido en todas las codificaciones probables hasta que se encuentre una coincidencia. Dibujando los valores en una tabla (en hexacedimal) se ve con claridad:

Latin1CP850UTF-8
ÑD1A5C3 91
ñF1A4C3 B1
ÐD0D1C3 90
ÃC3C7C3 83
±B1F1C2 B1
NOTA: Recuerda que Latin1 es igual ISO-8859-1 y muy parecida a Windows-1252, mientras que CP850 es algo parecida a CP437.

De esta tabla se deduce fácilmente que la palabra "CORUÐA" aparece por codificar con Latin1 y descodificar con CP850. La palabra "Coruña" es una confusión entre UTF-8 y Latin1, de hecho casi todas las confusiones entre estas dos codificaciones empiezan por à (0xC3). Finalmente, la aparición de "Coru�a" es el resultado de codificar con cualquier codificación de 8 bits e intentar descodificar con UTF-8. El carácter resultante no es válido, y el descodificador UTF-8 lo sustituye por un signo de interrogación.

4 comentarios a Programando con Unicode

  1. http://www.enrupt.com/index.php/2010/07/07/skype-biggest-secret-revealed

  2. Dejate de joder, que clase de idiota no sabe diferenciar entre un char, int y long? que se dediquen a otra cosa entonces, facilmente se puede usar unicodes con estos otros tipos, no solo se depende del char para escribir.

  3. Muy interesante y muy bien expuesto

  4. Muy interesante y aclaratoria la explicación. Se agradece.

Ayuda
:-(icon_sad :-)icon_smile :roll:icon_rolleyes
:-Dicon_biggrin :-Picon_razz :oops:icon_redface
:-xicon_mad :-|icon_neutral :arrow:icon_arrow
8-)icon_cool 8-Oicon_eek :mrgreen:icon_mrgreen
:!:icon_exclaim :?:icon_question :twisted:icon_twisted
;-)icon_wink :evil:icon_evil :idea:icon_idea
:-oicon_surprised :cry:icon_cry
:-?icon_confused :lol:icon_lol

Deja un comentario