Hace algún tiempo (varios años xD) escribí un post sobre encodings en Unicode en el que comentaba un poco el lío de codificaciones que tenemos en Unicode, y que significa UTF-8 o UTF-16. Te lo puedes leer si quieres pero si no aquí tienes un pequeño resúmen de lo más importante que comentaba y que es necesario para entender de lo que quiero hablarte hoy.
Lo más importante de aquel post eran los siguientes tres puntos:
- El concepto code point en Unicode que es lo que equivaldría a “carácter”. Hay muchos (más de 2 a la 16) code points distintos en Unicode
- El concepto de code unit (valor de 16 bits) que es propio de UTF-16. Un code point puede estar formado por varios code units.
- En .NET un
System.Char
representa un code unit y no un code point.
El tercer punto es clave ya que significa que:
- Una variable
char
es posible que no se corresponden a ningún carácter real (no todos los code unit se corresponen a un code point). - Hay carácteres Unicode que no se pueden representar usando un
char
y necesitamos más de uno.
Runas (aka code points)
El nombre de runa suena muy esotérico y no está definido en ningún estándard pero quedémonos con que una runa es exactamente lo mismo que un code point pero en nomenclatura C# (eso viene de que esa parte de la API de .NET está fuertemente inspirada en su equivalente de Golang y en Golang eligieron dicho nombre).
Así una runa es un valor de 32 bits (como podría ser un System.Int32
) que se mapea a un code point, es decir a un carácter Unicode. Vamos a verlo. Imagina el siguiente código:
|
|
¿Cuál es el resultado de ese programa? Supongo que todos habéis dicho 1, ya que la cadena "A"
tiene un carácter. Efectivamente la salida es 1. Sigamos:
|
|
¿Y ahora cual es la salida? Uno podría pensar que debería seguir siendo 1, ya que tenemos un solo carácter (recuerda que en Unicode los emojis son carácteres como los demás). Pero la realidad es que la salida es 2. ¿Y por qué es 2 la salida, si solo hay un carácter? Pues muy sencillo: porque en .NET una cadena no contiene carácteres (code points) si no que contiene los code units de UTF-16. El tipo System.Char
representa un code unit, no un code point y para el carácter Unicode ‘😀’ se requieren 2 code units de UTF-16 para codificarlo. Las cadenas en .NET están en UTF-16 y este hecho se filtra en la API: NO tenemos una API orientada a carácteres, tenemos una API orientada a code units de UTF-16. No te creas que eso es un problema de los emojis solo: muchos carácteres de lenguas extrangeras requieren más de un code unit para almacenarlos por lo que necesitamos más de un System.Char
de .NET.
Para solucionar este problema, en netcore 3 se introdujo la API de runas:
|
|
El método EnumerateRunes
devuelve un StringRuneEnumerator
que nos permite iterar sobre las runas de la cadena. Recuerda que una runa es un code point de Unicode, es decir un carácter.
La salida de este programa ahora si que nos indica que sólamente hay una runa. La salida es como sigue:
Rune is 😀 with value 128512
El valor numérico que nos aparece (128512 o 0x1f600) es el valor del code point correspondiente de Unicode y es de 32 bits. Por lo tanto el código correcto para saber cuantos carácteres reales tiene una cadena NO es usar String.Length
si no usar String.EnumerateRunes().Count()
.
Si quisieras repetir este ejercicio pero en lugar de usar el emoticono metido en el fichero, quisieras usar su codificación Unicode, la cosa no es tan sencilla:
|
|
Este código NO imprime el emoticono si no que en su lugar imprime ὠ0
. ¿Qué ocurre aquí? Pues sencillamente que la notación \u
nos permite indicar el código Unicode, pero el código Unicode ¿de qué? No de un code point si no de un code unit de UTF-16 (es decir de un System.Char
). Por lo tanto después de \u
siempre van 4 dígitos hexadecimales (de 0000
a ffff
) que nos cubren los 16 bits possibles. Por lo que la cadena "\u1f600"
se toma como una cadena con dos code units, el primero con código Unicode 1f60
y el segundo es el carácter 0
. Si quieres construir el emoji debes saber como se codifica en UTF-16:
|
|
Este código si que imprime el emoticono por pantalla ya que s
ya que el emoticono 😀 en UTF-16 se codifica mediante dos code units (0xd83d
y 0xde00
), y como un System.Char
de .NET equivale a un code unit de UTF-16 y un System.String
es una colección de System.Char
pues nos toca poner la codificación UTF-16 tal cual. Obviamente da igual si usamos los dos System.Char
explícitos en la cadena o el emoji directo, el código que detecta las runas funciona igual (ya que realmente ambas cadenas son la misma). Así el siguiente código imprime True
, ya que la condición es cierta:
|
|
Resumiendo: la API de Runas de C# nos permite obtener los carácteres Unicode (code points) de la cadena, en lugar de los code units de UTF-16. La realidad es que aquí se nota que la API de cadenas de .NET tiene sus añitos (recuerda que en nada .NET cumple 20 años xD) ya que, estoy seguro, si se diseñase ahora desde 0, el valor de System.Char
se correspondería a un code point de Unicode y en todo caso tendríamos métodos para obtener una codificación UTF-16 (o UTF-8 o lo que sea) de un System.Char
.
Igual te preguntas cuando deberías usar runas y no System.Char
al trabajar con cadenas. La realidad es que deberías hacerlo siempre que quieras soportar cualquier posible carácter Unicode. Usando char
entras en riesgo si el carácter cae fuera de cierto rango (el que llamamos rango BMP). Por ejemplo, el siguiente código falla miserablemente al contar las letras de la cadena s
:
|
|
El valor de letters
es 0. Por otro lado si usamos la API de runas todo funciona correctamente:
|
|
Ahora sí que letters
tiene el valor correcto (8
). La clase System.Text.Rune
tiene muchos de los métodos estáticos que hay en System.Char
incluyendo ToUpper
o ToLower
por ejemplo.
Grafemas
Vale, ya tenemos claro que si una cadena tiene 3 runas, es que el usuario verá 3 carácteres en pantalla ¿no? Pues no. Bienvenido al séptimo círculo infernal de Unicode: los grafemas.
Un grafema (el nombre técnico es grapheme cluster
) es el equivalente de carácteres percibidos en pantalla por el usuario. O sea, si una cadena contiene 2 grafemas el usuario verá dos carácteres en pantalla. Así que poniéndolo todo junto:
- Una
System.String
puede contener varioscode units
de UTF-16.- Cada
code unit
se representa mediante unSystem.Char
.
- Cada
- Todos esos code units son la representación en UTF-16 de varios code points de Unicode.
- Cada code point se representa mediante un
System.Text.Rune
.
- Cada code point se representa mediante un
- Todos esos code points de Unicode son la representación de varios grafemas
- Por cada grafema el usuario percibirá un carácter
Vamos a ver un ejemplo de grafema. Por ejemplo el carácter “👩👩👧👧”. Esta adorable família de dos madres y dos niñas no es un code point de Unicode. No hay ningún carácter Unicode que represente a esta família. En su lugar existe una combinación de code points que forman un grafema. Concretamente en este caso hay 7 code points que se combinan para formar ese grafema. Los podemos ver uno a uno usando la API de runas de C#:
|
|
La salida de ese programa es tal y como se muestra a combinación:
Rune is 👩 with value 128105
Rune is with value 8205
Rune is 👩 with value 128105
Rune is with value 8205
Rune is 👧 with value 128103
Rune is with value 8205
Rune is 👧 with value 128103
El code point 8205 es un code point “especial”, se llama ZWJ (Zero Width Joiner) y se usa, precisamente, en este tipo de combinaciones: para combinar varios code points y formar un grafema. ¡Ojo! Que el 8205 no es el único code point especial, hay muchos más que se usan en otros grafemas. El estándard Unicode pues, no solo define las tablas de carácteres (code points) y como se codifican (UTF-16, UTF-8, etc) si no que también define posibles combinaciones de code points para formar distintos grafemas. De hecho, puedes ver que 👩👩👧👧 está formado por varios carácteres si, usando Visual Studio Code, te situas justo después del carácter y empiezas a borrar:
Cada vez que borro, se elimina un code point por lo que el grafema es cada vez distinto, de ahí que el emoji vaya cambiando cada vez borramos.
Igual te preguntas si es posible en C# saber cuantos grafemas tiene una cadena. Pues la realidad es que sí. Podemos iterar sobre los grafemas de una cadena:
|
|
Este código imprime We have 1 graphemes
en pantalla, indicando que efectivamente la cadena “👩👩👧👧” contiene un solo grafema. También podemos obtener cada uno de los grafemas:
|
|
Ahora, aquí tenemos un pequeño problema y es que la variable grapheme
es de tipo object?
, ya que no hay un tipo específico para representar un grafema. De hecho el objeto devuelto por Current
es de tipo System.String
, o sea que podemos usar el siguiente código:
|
|
Por supuesto, puedes usar as
o incluso usar graphemes.Current?.ToString()
.
¿Y como es que para representar un grafema terminamos con una cadena? Eso es porque, al menos en la versión actual de la API (NET6), con los grafemas poco podemos hacer: imprimirlos por pantalla, contarlos y por supuesto (dado que son cadenas) saber sus runas:
|
|
La salida de este código es tal y como sigue:
Grapheme 1:
Rune is 👩 with code 128105
Rune is with code 8205
Rune is 👩 with code 128105
Rune is with code 8205
Rune is 👧 with code 128103
Rune is with code 8205
Rune is 👧 with code 128103
We have 1 graphemes
Por supuesto, un grafema, dado que es una cadena lo podemos imprimir tal cual por consola:
|
|
La salida de este código debería ser la siguiente:
Grapheme:👩👩👧
Pero la realidad es que la salida puede ser cualquier cosa ya que el soporte de grafemas en terminales está un poco verde. Es decir, es posible que en tu terminal no lo veas bien. Pero la culpa no es de .NET, es del terminal que no gestiona la visualización de la combinación de code units como un grafema.
Algunas muestras. Muestro una captura de pantalla de la salida del programa junto con el contenido del fichero Program.cs
. Empecemos por Windows Terminal en su versión 1.11.3471.0:
En esta segunda imagen se muestra como se ve en el terminal integrado de VSCode:
Y en esta tercera como se ve en el terminal por defecto de Ubuntu 21.10:
Como puedes ver, cada terminal lo gestiona distinto. Visual Studio Code es curioso porque en el editor el grafema se ve perfecto, pero en el terminal no.
En resumen, en este post hemos visto las diferencias entre un System.Char
de .NET, un carácter Unicode y un grafema. Espero que este post te haya podido aclarar un poco las ideas y de paso hacerte ver que el soporte que tenemos en .NET para tratar con Unicode es ahora mismo muy bueno.