¿Por qué los caracteres emoji como 👩‍👩‍👧‍👦 son tratados de manera tan extraña en las cadenas Swift?

540

El carácter 👩‍👩‍👧‍👦 (familia con dos mujeres, una niña y un niño) está codificado como tal:

U+1F469 WOMAN`
‍U+200D ZWJ`
U+1F469 WOMAN`
U+200D ZWJ`
U+1F467 GIRL`
U+200D ZWJ`
U+1F466 BOY

Entonces está muy interesantemente codificado; El blanco perfecto para una prueba unitaria. Sin embargo, Swift no parece saber cómo tratarlo. Esto es lo que quiero decir:

"👩‍👩‍👧‍👦".contains("👩‍👩‍👧‍👦") // true
"👩‍👩‍👧‍👦".contains("👩") // false
"👩‍👩‍👧‍👦".contains("\u{200D}") // false
"👩‍👩‍👧‍👦".contains("👧") // false
"👩‍👩‍👧‍👦".contains("👦") // true

Entonces, Swift dice que se contiene a sí mismo (bueno) y a un niño (¡bueno!). Pero luego dice que no contiene una mujer, una niña o un carpintero de ancho cero. ¿Que esta pasando aqui? ¿Por qué Swift sabe que contiene un niño pero no una mujer o una niña? Podía entender si lo trataba como un solo personaje y solo reconocía que se contenía a sí mismo, pero el hecho de que tuviera un subcomponente y ningún otro me desconcierta.

Esto no cambia si uso algo como "👩".characters.first!.


Aún más confuso es esto:

let manual = "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}"
Array(manual.characters) // ["👩‍", "👩‍", "👧‍", "👦"]

Aunque coloqué los ZWJ allí, no se reflejan en la matriz de caracteres. Lo que siguió fue un poco revelador:

manual.contains("👩") // false
manual.contains("👧") // false
manual.contains("👦") // true

Entonces obtengo el mismo comportamiento con la matriz de caracteres ... lo cual es sumamente molesto, ya que sé cómo se ve la matriz.

Esto tampoco cambia si uso algo como "👩".characters.first!.

Ben Leggiero
fuente
1
Los comentarios no son para discusión extendida; Esta conversación se ha movido al chat .
Martijn Pieters
1
Se "👩‍👩‍👧‍👦".contains("\u{200D}")corrigió en Swift 4. todavía devuelve falso, no estoy seguro de si eso es un error o característica
Kevin
44
Yikes Unicode ha arruinado el texto. Se convirtió el texto plano en un lenguaje de marcado.
Boann
66
@Boann sí y no ... se introdujeron muchos de estos cambios para hacer que decodificar / decodificar cosas como Hangul Jamo (255 puntos de código) no sea una pesadilla absoluta como lo fue para Kanji (13,108 puntos de código) e Ideogramas chinos (199,528 puntos de código). Por supuesto, es más complicado e interesante de lo que podría permitir un comentario SO, por lo que le animo a que lo revise usted mismo: D
Ben Leggiero

Respuestas:

402

Esto tiene que ver con cómo funciona el Stringtipo en Swift y cómo funciona el contains(_:)método.

La '👩‍👩‍👧‍👦' es lo que se conoce como una secuencia de emoji, que se representa como un carácter visible en una cadena. La secuencia está compuesta de Characterobjetos, y al mismo tiempo está compuesta de UnicodeScalarobjetos.

Si verifica el recuento de caracteres de la cadena, verá que está formado por cuatro caracteres, mientras que si verifica el recuento escalar unicode, le mostrará un resultado diferente:

print("👩‍👩‍👧‍👦".characters.count)     // 4
print("👩‍👩‍👧‍👦".unicodeScalars.count) // 7

Ahora, si analiza los caracteres e los imprime, verá lo que parecen caracteres normales, pero de hecho, los tres primeros caracteres contienen tanto un emoji como un carpintero de ancho cero en su UnicodeScalarView:

for char in "👩‍👩‍👧‍👦".characters {
    print(char)

    let scalars = String(char).unicodeScalars.map({ String($0.value, radix: 16) })
    print(scalars)
}

// 👩‍
// ["1f469", "200d"]
// 👩‍
// ["1f469", "200d"]
// 👧‍
// ["1f467", "200d"]
// 👦
// ["1f466"]

Como puede ver, solo el último carácter no contiene una unión de ancho cero, por lo que cuando usa el contains(_:)método, funciona como es de esperar. Como no se compara con los emoji que contienen uniones de ancho cero, el método no encontrará una coincidencia para ningún otro personaje que no sea el último.

Para ampliar esto, si crea uno Stringque está compuesto por un carácter emoji que termina con una unión de ancho cero y lo pasa al contains(_:)método, también se evaluará false. Esto tiene que ver con contains(_:)ser exactamente igual que range(of:) != nil, que trata de encontrar una coincidencia exacta con el argumento dado. Dado que los caracteres que terminan con una unión de ancho cero forman una secuencia incompleta, el método intenta encontrar una coincidencia para el argumento mientras combina caracteres que terminan con uniones de ancho cero en una secuencia completa. Esto significa que el método nunca encontrará una coincidencia si:

  1. el argumento termina con una unión de ancho cero, y
  2. la cadena a analizar no contiene una secuencia incompleta (es decir, que termina con una unión de ancho cero y no seguida de un carácter compatible).

Demostrar:

let s = "\u{1f469}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}" // 👩‍👩‍👧‍👦

s.range(of: "\u{1f469}\u{200d}") != nil                            // false
s.range(of: "\u{1f469}\u{200d}\u{1f469}") != nil                   // false

Sin embargo, dado que la comparación solo mira hacia adelante, puede encontrar varias otras secuencias completas dentro de la cadena trabajando hacia atrás:

s.range(of: "\u{1f466}") != nil                                    // true
s.range(of: "\u{1f467}\u{200d}\u{1f466}") != nil                   // true
s.range(of: "\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}") != nil  // true

// Same as the above:
s.contains("\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}")          // true

La solución más fácil sería proporcionar una opción de comparación específica para el range(of:options:range:locale:)método. La opción String.CompareOptions.literalrealiza la comparación en una equivalencia exacta de carácter por carácter . Como nota al margen, lo que se entiende por carácter aquí no es Swift Character, sino la representación UTF-16 de la cadena de instancia y de comparación; sin embargo, dado Stringque no permite UTF-16 con formato incorrecto, esto es esencialmente equivalente a comparar el escalar Unicode representación.

Aquí he sobrecargado el Foundationmétodo, así que si necesitas el original, renombra este o algo así:

extension String {
    func contains(_ string: String) -> Bool {
        return self.range(of: string, options: String.CompareOptions.literal) != nil
    }
}

Ahora el método funciona como "debería" con cada personaje, incluso con secuencias incompletas:

s.contains("👩")          // true
s.contains("👩\u{200d}")  // true
s.contains("\u{200d}")    // true
xoudini
fuente
47
@MartinR Según el UTR29 actual (Unicode 9.0), es un clúster de grafema extendido ( reglas GB10 y GB11 ), pero Swift claramente usa una versión anterior. Aparentemente, arreglar eso es un objetivo para la versión 4 del lenguaje , por lo que este comportamiento cambiará en el futuro.
Michael Homer
99
@MichaelHomer: Al parecer, eso se ha solucionado, se "👩‍👩‍👧‍👦".countevalúa 1con la versión actual de Xcode 9 beta y Swift 4.
Martin R
55
Guau. Esto es excelente. Pero ahora me estoy poniendo nostálgico por los viejos tiempos cuando el peor problema que tuve con las cadenas es si usan codificaciones de estilo C o Pascal.
Owen Godfrey
2
Entiendo por qué el estándar Unicode puede necesitar soportar esto, pero hombre, este es un desastre de ingeniería excesiva, si acaso: /
Vuelva a instalar a Mónica el
110

El primer problema es que estás conectando a Foundation con contains(Swift Stringno es un Collection), así que este es el NSStringcomportamiento, que no creo que maneje Emoji compuesto tan poderosamente como Swift. Dicho esto, Swift, creo, está implementando Unicode 8 en este momento, que también necesitaba revisión en torno a esta situación en Unicode 10 (por lo que todo esto puede cambiar cuando implementan Unicode 10; no he investigado si lo hará o no).

Para simplificar, eliminemos Foundation y usemos Swift, que proporciona vistas más explícitas. Comenzaremos con los personajes:

"👩‍👩‍👧‍👦".characters.forEach { print($0) }
👩‍
👩‍
👧‍
👦

OKAY. Eso es lo que esperábamos. Pero es mentira. Veamos cuáles son esos personajes realmente.

"👩‍👩‍👧‍👦".characters.forEach { print(String($0).unicodeScalars.map{$0}) }
["\u{0001F469}", "\u{200D}"]
["\u{0001F469}", "\u{200D}"]
["\u{0001F467}", "\u{200D}"]
["\u{0001F466}"]

Ah ... así es ["👩ZWJ", "👩ZWJ", "👧ZWJ", "👦"]. Eso deja todo un poco más claro. 👩 no es miembro de esta lista (es "👩ZWJ"), pero 👦 es miembro.

El problema es que Characteres un "grupo de grafemas", que compone cosas juntas (como adjuntar el ZWJ). Lo que realmente estás buscando es un escalar unicode. Y eso funciona exactamente como esperabas:

"👩‍👩‍👧‍👦".unicodeScalars.contains("👩") // true
"👩‍👩‍👧‍👦".unicodeScalars.contains("\u{200D}") // true
"👩‍👩‍👧‍👦".unicodeScalars.contains("👧") // true
"👩‍👩‍👧‍👦".unicodeScalars.contains("👦") // true

Y, por supuesto, también podemos buscar el personaje real que está allí:

"👩‍👩‍👧‍👦".characters.contains("👩\u{200D}") // true

(Esto duplica en gran medida los puntos de Ben Leggiero. Publiqué esto antes de notar que había respondido. Partir en caso de que sea más claro para alguien).

Rob Napier
fuente
¿ ZWJQué significa?
LinusGeffarth
2
Zero Width Joiner
Rob Napier
@RobNapier en Swift 4, Stringsupuestamente se cambió de nuevo a un tipo de colección. ¿Eso afecta tu respuesta?
Ben Leggiero
No. Eso solo cambió cosas como la suscripción. No cambió la forma en que funcionan los personajes.
Rob Napier
75

Parece que Swift considera que ZWJa es un grupo de grafemas extendido con el personaje inmediatamente anterior. Podemos ver esto cuando asignamos la matriz de caracteres a sus unicodeScalars:

Array(manual.characters).map { $0.description.unicodeScalars }

Esto imprime lo siguiente de LLDB:

4 elements
  ▿ 0 : StringUnicodeScalarView("👩‍")
    - 0 : "\u{0001F469}"
    - 1 : "\u{200D}"1 : StringUnicodeScalarView("👩‍")
    - 0 : "\u{0001F469}"
    - 1 : "\u{200D}"2 : StringUnicodeScalarView("👧‍")
    - 0 : "\u{0001F467}"
    - 1 : "\u{200D}"3 : StringUnicodeScalarView("👦")
    - 0 : "\u{0001F466}"

Además, los .containsgrupos extendieron los grupos de grafemas en un solo personaje. Por ejemplo, tomando los caracteres de Hangul , y (que se combinan para hacer que la palabra coreana para "uno": 한):

"\u{1112}\u{1161}\u{11AB}".contains("\u{1112}") // false

Esto no se pudo encontrar porque los tres puntos de código se agrupan en un clúster que actúa como un solo carácter. Del mismo modo, \u{1F469}\u{200D}( WOMAN ZWJ) es un grupo, que actúa como un carácter.

Ben Leggiero
fuente
19

Las otras respuestas discuten lo que hace Swift, pero no entran en muchos detalles sobre por qué.

¿Espera que "Å" sea igual a "Å"? Espero que lo hagas.

Una de ellas es una letra con un combinador, la otra es un único personaje compuesto. Puedes agregar muchos combinadores diferentes a un personaje base, y un humano aún lo consideraría como un personaje único. Para lidiar con este tipo de discrepancia, se creó el concepto de grafema para representar lo que un humano consideraría un personaje, independientemente de los puntos de código utilizados.

Ahora los servicios de mensajes de texto han estado combinando caracteres en emoji gráficos durante años :) →  🙂. Así que se agregaron varios emoji a Unicode.
Estos servicios también comenzaron a combinar emoji en emoji compuesto.
Por supuesto, no hay una forma razonable de codificar todas las combinaciones posibles en puntos de código individuales, por lo que El Consorcio Unicode decidió ampliar el concepto de grafemas para abarcar estos caracteres compuestos.

Lo que se reduce a esto se "👩‍👩‍👧‍👦"debe considerar como un solo "grupo de grafemas" si intenta trabajar con él a nivel de grafema, como lo hace Swift de manera predeterminada.

Si quieres comprobar si contiene "👦" como parte de eso, entonces debe bajar a un nivel inferior.


No conozco la sintaxis de Swift, así que aquí hay un Perl 6 que tiene un nivel similar de soporte para Unicode.
(Perl 6 admite la versión 9 de Unicode, por lo que puede haber discrepancias)

say "\c[family: woman woman girl boy]" eq "👩‍👩‍👧‍👦"; # True

# .contains is a Str method only, in Perl 6
say "👩‍👩‍👧‍👦".contains("👩‍👩‍👧‍👦")    # True
say "👩‍👩‍👧‍👦".contains("👦");        # False
say "👩‍👩‍👧‍👦".contains("\x[200D]");  # False

# comb with no arguments splits a Str into graphemes
my @graphemes = "👩‍👩‍👧‍👦".comb;
say @graphemes.elems;                # 1

Bajemos un nivel

# look at it as a list of NFC codepoints
my @components := "👩‍👩‍👧‍👦".NFC;
say @components.elems;                     # 7

say @components.grep("👦".ord).Bool;       # True
say @components.grep("\x[200D]".ord).Bool; # True
say @components.grep(0x200D).Bool;         # True

Sin embargo, bajar a este nivel puede hacer que algunas cosas sean más difíciles.

my @match = "👩‍👩‍👧‍👦".ords;
my $l = @match.elems;
say @components.rotor( $l => 1-$l ).grep(@match).Bool; # True

yo asumo eso .contains en Swift lo hace más fácil, pero eso no significa que no haya otras cosas que se vuelvan más difíciles.

Trabajar en este nivel hace que sea mucho más fácil dividir accidentalmente una cadena en el medio de un carácter compuesto, por ejemplo.


Lo que inadvertidamente pregunta es por qué esta representación de nivel superior no funciona como lo haría una representación de nivel inferior. La respuesta es, por supuesto, no se supone que lo haga.

Si se pregunta " por qué esto tiene que ser tan complicado ", la respuesta es, por supuesto, " humanos ".

Brad Gilbert
fuente
44
Me perdiste en tu última línea de ejemplo; que hacer rotory que grephacer aqui Y lo que es 1-$l?
Ben Leggiero
44
El término "grafema" tiene al menos 50 años. Unicode lo introdujo en el estándar porque ya habían usado el término "personaje" para significar algo bastante diferente de lo que normalmente se piensa como un personaje. Puedo leer lo que escribiste como coherente con eso, pero sospecho que otros podrían tener una impresión equivocada, de ahí este comentario (espero que aclare).
raiph
2
@BenLeggiero En primer lugar, rotor. El código say (1,2,3,4,5,6).rotor(3)cede ((1 2 3) (4 5 6)). Esa es una lista de listas, cada longitud 3. say (1,2,3,4,5,6).rotor(3=>-2)produce lo mismo, excepto que la segunda sublista comienza con, en 2lugar de 4, la tercera con 3, y así sucesivamente, cediendo ((1 2 3) (2 3 4) (3 4 5) (4 5 6)). Si @matchcontiene, "👩‍👩‍👧‍👦".ordsentonces el código de @ Brad crea solo una sublista, por lo que el =>1-$lbit es irrelevante (no utilizado). Solo es relevante si @matches más corto que @components.
raiph
1
grepintenta hacer coincidir cada elemento en su invocante (en este caso, una lista de sublistas de @components). Intenta hacer coincidir cada elemento con su argumento de coincidencia (en este caso, @match). Los .Boolrendimientos luego TrueFIB la grepproduce al menos un partido.
raiph
18

Actualización de Swift 4.0

String recibió muchas revisiones en la actualización de Swift 4, como se documenta en SE-0163 . Se utilizan dos emoji para esta demostración que representan dos estructuras diferentes. Ambos se combinan con una secuencia de emoji.

👍🏽es la combinación de dos emoji 👍y🏽

👩‍👩‍👧‍👦es la combinación de cuatro emoji, con carpintero de ancho cero conectado. El formato es👩‍joiner👩‍joiner👧‍joiner👦

1. Cuenta

En Swift 4.0, los emoji se cuentan como un grupo de grafemas. Cada emoji se cuenta como 1. La countpropiedad también está disponible directamente para la cadena. Entonces puedes llamarlo directamente así.

"👍🏽".count  // 1. Not available on swift 3
"👩‍👩‍👧‍👦".count  // 1. Not available on swift 3

La matriz de caracteres de una cadena también se cuenta como grupos de grafemas en Swift 4.0, por lo que se imprimen los dos códigos siguientes 1. Estos dos emoji son ejemplos de secuencias de emoji, donde varios emoji se combinan con o sin unión de ancho cero \u{200d}entre ellos. En swift 3.0, la matriz de caracteres de dicha cadena separa cada emoji y da como resultado una matriz con múltiples elementos (emoji). El carpintero se ignora en este proceso. Sin embargo, en Swift 4.0, la matriz de caracteres ve todos los emoji como una sola pieza. Entonces, el de cualquier emoji siempre será 1.

"👍🏽".characters.count  // 1. In swift 3, this prints 2
"👩‍👩‍👧‍👦".characters.count  // 1. In swift 3, this prints 4

unicodeScalars permanece sin cambios en Swift 4. Proporciona los caracteres únicos Unicode en la cadena dada.

"👍🏽".unicodeScalars.count  // 2. Combination of two emoji
"👩‍👩‍👧‍👦".unicodeScalars.count  // 7. Combination of four emoji with joiner between them

2. Contiene

En Swift 4.0, el containsmétodo ignora la unión de ancho cero en emoji. Por lo tanto, devuelve verdadero para cualquiera de los cuatro componentes emoji de "👩‍👩‍👧‍👦", y devuelve falso si verifica la unión. Sin embargo, en Swift 3.0, el carpintero no se ignora y se combina con el emoji frente a él. Entonces, cuando verifica si "👩‍👩‍👧‍👦"contiene los primeros tres componentes emoji, el resultado será falso

"👍🏽".contains("👍")       // true
"👍🏽".contains("🏽")        // true
"👩‍👩‍👧‍👦".contains("👩‍👩‍👧‍👦")       // true
"👩‍👩‍👧‍👦".contains("👩")       // true. In swift 3, this prints false
"👩‍👩‍👧‍👦".contains("\u{200D}") // false
"👩‍👩‍👧‍👦".contains("👧")       // true. In swift 3, this prints false
"👩‍👩‍👧‍👦".contains("👦")       // true
Colmillo
fuente
0

Los emojis, al igual que el estándar Unicode, son engañosamente complicados. Los tonos de piel, los géneros, los trabajos, los grupos de personas, las secuencias de carpintería de ancho cero, las banderas (2 caracteres unicode) y otras complicaciones pueden hacer que el análisis de emoji sea desordenado. Un árbol de Navidad, una rebanada de pizza o una pila de caca se pueden representar con un único punto de código Unicode. Sin mencionar que cuando se introducen nuevos emojis, hay un retraso entre el soporte de iOS y el lanzamiento de emoji. Eso y el hecho de que diferentes versiones de iOS admiten diferentes versiones del estándar Unicode.

TL; DR. Trabajé en estas características y abrí una biblioteca. Soy el autor de JKEmoji para ayudar a analizar cadenas con emojis. Hace que el análisis sea tan fácil como:

print("I love these emojis 👩‍👩‍👧‍👦💪🏾🧥👧🏿🌈".emojiCount)

5 5

Lo hace actualizando rutinariamente una base de datos local de todos los emojis reconocidos a partir de la última versión Unicode ( 12.0 a la fecha reciente) y haciendo referencias cruzadas con lo que se reconoce como un emoji válido en la versión del sistema operativo en ejecución al observar la representación de mapa de bits de Un personaje emoji no reconocido.

NOTA

Se eliminó una respuesta anterior por anunciar mi biblioteca sin indicar claramente que yo soy el autor. Estoy reconociendo esto nuevamente.

Joe
fuente
2
Si bien estoy impresionado por su biblioteca, y veo cómo se relaciona generalmente con el tema en cuestión, no veo cómo esto se relaciona directamente con la pregunta
Ben Leggiero