Por que caracteres emoji como 👩‍👩‍👧‍👦 são tratados de maneira tão estranha nas cordas Swift?

540

O personagem 👩‍👩‍👧‍👦 (família com duas mulheres, uma garota e um menino) é codificado da seguinte forma:

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

Portanto, é muito interessante codificado; o alvo perfeito para um teste de unidade. No entanto, Swift parece não saber como tratá-lo. Aqui está o que eu quero dizer:

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

Então, Swift diz que se contém (bom) e um menino (bom!). Mas, então, diz que não contém uma mulher, menina ou marceneiro de largura zero. O que está acontecendo aqui? Por que Swift sabe que contém um menino, mas não uma mulher ou menina? Eu podia entender se o tratava como um único personagem e o reconhecia apenas contendo a si mesmo, mas o fato de ter um subcomponente e nenhum outro me deixa perplexo.

Isso não muda se eu usar algo parecido "👩".characters.first!.


Ainda mais confuso é o seguinte:

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

Embora eu tenha colocado os ZWJs lá, eles não são refletidos na matriz de caracteres. O que se seguiu foi um pouco revelador:

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

Então, eu recebo o mesmo comportamento com a matriz de caracteres ... o que é extremamente irritante, pois sei como é a matriz.

Isso também não muda se eu usar algo parecido "👩".characters.first!.

Ben Leggiero
fonte
1
Comentários não são para discussão prolongada; esta conversa foi movida para o bate-papo .
Martijn Pieters
1
Corrigido no Swift 4. "👩‍👩‍👧‍👦".contains("\u{200D}")ainda retorna false, não tenho certeza se isso é um bug ou recurso.
21717 Kevin
4
Caramba. O Unicode arruinou o texto. Ele transformou o texto sem formatação em uma linguagem de marcação.
Boann
6
@Boann sim e não ... muitas dessas alterações foram feitas para tornar as coisas de decodificação / decodificação como Hangul Jamo (255 pontos de código) não um pesadelo absoluto, como foi para Kanji (13.108 pontos de código) e ideogramas chineses (199.528 pontos de código). É claro que é mais complicado e interessante do que a duração de um comentário de SO pode permitir, por isso encorajo você a verificar você mesmo: D
Ben Leggiero

Respostas:

402

Isso tem a ver com a forma como o Stringtipo funciona no Swift e como o contains(_:)método funciona.

O '👩‍👩‍👧‍👦' é conhecido como uma sequência de emoji, que é renderizada como um caractere visível em uma string. A sequência é composta de Characterobjetos e, ao mesmo tempo, é composta de UnicodeScalarobjetos.

Se você verificar a contagem de caracteres da sequência, verá que ela é composta de quatro caracteres, enquanto que, se você verificar a contagem escalar unicode, mostrará um resultado diferente:

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

Agora, se você analisar os caracteres e imprimi-los, verá o que parece ser caracteres normais, mas, na verdade, os três primeiros caracteres contêm um emoji e um marceneiro de largura zero 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 você pode ver, apenas o último caractere não contém um marceneiro de largura zero; portanto, ao usar o contains(_:)método, ele funciona conforme o esperado. Como você não está comparando com emoji contendo marcadores de largura zero, o método não encontrará uma correspondência para nenhum, exceto o último caractere.

Para expandir isso, se você criar um Stringque é composto de um caractere emoji que termina com um marceneiro de largura zero e passá-lo para o contains(_:)método, ele também avaliará false. Isso tem a ver com contains(_:)ser exatamente o mesmo que range(of:) != nil, que tenta encontrar uma correspondência exata para o argumento fornecido. Como os caracteres que terminam com um marceneiro de largura zero formam uma sequência incompleta, o método tenta encontrar uma correspondência para o argumento enquanto combina os caracteres que terminam com marceneiros de largura zero em uma sequência completa. Isso significa que o método nunca encontrará uma correspondência se:

  1. o argumento termina com um marceneiro de largura zero e
  2. a sequência a ser analisada não contém uma sequência incompleta (ou seja, termina com um marceneiro de largura zero e não é seguida por um caractere compatível).

Para demonstrar:

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

No entanto, como a comparação apenas olha para o futuro, é possível encontrar várias outras sequências completas na sequência trabalhando de trás para frente:

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

A solução mais fácil seria fornecer uma opção de comparação específica para o range(of:options:range:locale:)método. A opção String.CompareOptions.literalexecuta a comparação com uma equivalência exata de caractere por caractere . Como observação, o significado de caractere aqui não é o Swift Character, mas a representação UTF-16 da instância e da string de comparação - no entanto, como Stringnão permite UTF-16 malformado, isso é essencialmente equivalente à comparação do escalar Unicode. representação.

Aqui eu sobrecarreguei o Foundationmétodo, portanto, se você precisar do original, renomeie este ou algo assim:

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

Agora, o método funciona como "deveria" com cada caractere, mesmo com sequências incompletas:

s.contains("👩")          // true
s.contains("👩\u{200d}")  // true
s.contains("\u{200d}")    // true
xoudini
fonte
47
@MartinR De acordo com o UTR29 atual (Unicode 9.0), é um cluster de grafema estendido ( regras GB10 e GB11 ), mas o Swift usa claramente uma versão mais antiga. Aparentemente, corrigir esse é um objetivo da versão 4 do idioma , portanto esse comportamento será alterado no futuro.
Michael Homer
9
@MichaelHomer: Aparentemente, isso foi corrigido, "👩‍👩‍👧‍👦".countavalia-se 1com o Xcode 9 beta atual e o Swift 4. #
Martin R
5
Uau. Isto e excelente. Mas agora estou ficando nostálgico nos velhos tempos, quando o pior problema que tive com as cordas é se elas usam codificações no estilo C ou Pascal.
Owen Godfrey
2
Eu entendo por que o padrão Unicode pode ter de suportar isso, mas o homem, esta é uma bagunça overengineered, se alguma coisa: /
Reintegrar Monica
110

O primeiro problema é que você está fazendo uma ponte para a Foundation contains(a Swift Stringnão é a Collection), então esse é um NSStringcomportamento, que eu não acredito que lida com Emoji composto tão poderosamente quanto Swift. Dito isso, acredito que a Swift esteja implementando o Unicode 8 no momento, que também precisava de revisão em torno dessa situação no Unicode 10 (portanto, tudo isso pode mudar quando eles implementam o Unicode 10; eu não procurei ou não).

Para simplificar, vamos nos livrar do Foundation e usar o Swift, que fornece visualizações mais explícitas. Começaremos com caracteres:

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

ESTÁ BEM. É o que esperávamos. Mas é mentira. Vamos ver o que esses personagens realmente são.

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

Ah ... então é ["👩ZWJ", "👩ZWJ", "👧ZWJ", "👦"]. Isso deixa tudo um pouco mais claro. 👩 não é membro desta lista (é "👩ZWJ"), mas 👦 é um membro.

O problema é que Characteré um "cluster grafema", que compõe as coisas juntas (como anexar o ZWJ). O que você realmente está procurando é um escalar unicode. E isso funciona exatamente como você está esperando:

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

E, é claro, também podemos procurar o personagem real que está lá:

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

(Isso duplica fortemente os pontos de Ben Leggiero. Publiquei isso antes de perceber que ele havia respondido. Partindo caso seja mais claro para alguém.)

Rob Napier
fonte
O que ZWJsignifica?
LinusGeffarth
2
Marceneiro com largura zero
Rob Napier
O @RobNapier no Swift 4 Stringfoi supostamente alterado novamente para um tipo de coleção. Isso afeta sua resposta?
Ben Leggiero
Não. Isso mudou coisas como a assinatura. Não mudou como os personagens funcionam.
Rob Napier
75

Parece que Swift considera ZWJa um grupo de grafemas estendido com o personagem imediatamente anterior a ele. Podemos ver isso ao mapear a matriz de caracteres para unicodeScalars:

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

Isso imprime o seguinte no 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}"

Além disso, os .containsgrupos estenderam grupos de grafemas em um único caractere. Por exemplo, tendo os caráteres de Hangul , , e (que se combinam para tornar a palavra coreana para "um": 한):

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

Não foi possível encontrar porque os três pontos de código estão agrupados em um cluster que atua como um caractere. Da mesma forma, \u{1F469}\u{200D}( WOMAN ZWJ) é um cluster, que atua como um caractere.

Ben Leggiero
fonte
19

As outras respostas discutem o que Swift faz, mas não entram em muitos detalhes sobre o porquê.

Você espera que "Å" seja igual a "Å"? Eu espero que você faça.

Uma delas é uma carta com um combinador, a outra é um caractere composto único. Você pode adicionar muitos combinadores diferentes a um personagem base, e um humano ainda consideraria um único personagem. Para lidar com esse tipo de discrepância, o conceito de grafema foi criado para representar o que um humano consideraria um personagem, independentemente dos pontos de código usados.

Agora, os serviços de mensagens de texto combinam caracteres em emoji gráfico há anos :) →  🙂. Então, vários emoji foram adicionados ao Unicode.
Esses serviços também começaram a combinar emoji emoji composto.
É claro que não há uma maneira razoável de codificar todas as combinações possíveis em pontos de código individuais, portanto, o Unicode Consortium decidiu expandir o conceito de grafemas para abranger esses caracteres compostos.

O que isso se resume a isso "👩‍👩‍👧‍👦"deve ser considerado como um único "cluster de grafema" se você tentar trabalhar com ele no nível do grafema, como o Swift faz por padrão.

Se você quiser verificar se ele contém "👦"parte disso, desça para um nível mais baixo.


Eu não conheço a sintaxe do Swift, então aqui estão alguns Perl 6 que têm nível de suporte semelhante para Unicode.
(Perl 6 suporta Unicode versão 9, portanto, pode haver discrepâncias)

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

Vamos descer um nível

# 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

Descer a este nível pode dificultar algumas coisas.

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

Presumo que .containsem Swift torne isso mais fácil, mas isso não significa que não há outras coisas que se tornam mais difíceis.

Trabalhar nesse nível facilita muito a divisão acidental de uma sequência no meio de um caractere composto, por exemplo.


O que você está perguntando inadvertidamente é por que essa representação de nível superior não funciona como uma representação de nível inferior. A resposta é claro, não deveria.

Se você está se perguntando " por que isso tem que ser tão complicado ", a resposta é obviamente " humanos ".

Brad Gilbert
fonte
4
Você me perdeu na sua última linha de exemplo; o que faz rotore grepfaz aqui? E o que é 1-$l?
precisa saber é o seguinte
4
O termo "grafema" tem pelo menos 50 anos. O Unicode o introduziu no padrão porque eles já haviam usado o termo "caractere" para significar algo bem diferente do que normalmente se pensa como um personagem. Eu posso ler o que você escreveu como consistente com isso, mas suspeito que outros possam ter uma impressão errada, daí esse comentário (que espero esclarecer).
raiph
2
@BenLeggiero Primeiro rotor,. O código say (1,2,3,4,5,6).rotor(3)produz ((1 2 3) (4 5 6)). Essa é uma lista de listas, cada comprimento 3. say (1,2,3,4,5,6).rotor(3=>-2)produz o mesmo, exceto que a segunda sub-lista começa com 2e não 4a terceira com 3e assim por diante ((1 2 3) (2 3 4) (3 4 5) (4 5 6)). Se @matchcontiver "👩‍👩‍👧‍👦".ords, o código de @ Brad cria apenas uma sub-lista; portanto, o =>1-$lbit é irrelevante (não utilizado). Só é relevante se @matchfor menor que @components.
raiph
1
greptenta corresponder a cada elemento em seu invocante (nesse caso, uma lista de sublistas de @components). Ele tenta corresponder cada elemento ao seu argumento de correspondência (neste caso, @match). Em .Boolseguida, retorna Truese grepproduz pelo menos uma correspondência.
raiph
18

Atualização do Swift 4.0

A String recebeu muitas revisões na atualização do Swift 4, conforme documentado no SE-0163 . Dois emoji são usados ​​para esta demonstração, representando duas estruturas diferentes. Ambos são combinados com uma sequência de emoji.

👍🏽é a combinação de dois emoji 👍e🏽

👩‍👩‍👧‍👦é a combinação de quatro emojis, com o marceneiro de largura zero conectado. O formato é👩‍joiner👩‍joiner👧‍joiner👦

1. Conta

No Swift 4.0, os emojis são contados como cluster de grafema. Cada emoji é contado como 1. A countpropriedade também está disponível diretamente para a sequência. Então você pode chamá-lo diretamente assim.

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

A matriz de caracteres de uma sequência também é contada como agrupamentos de grafema no Swift 4.0, portanto, os dois códigos a seguir imprimem 1. Esses dois emoji são exemplos de sequências de emoji, em que vários emoji são combinados com ou sem marcadores de largura zero \u{200d}entre eles. No swift 3.0, a matriz de caracteres dessa string separa cada emoji e resulta em uma matriz com vários elementos (emoji). O marceneiro é ignorado nesse processo. No entanto, no Swift 4.0, a matriz de caracteres vê todos os emoticons como uma peça. Assim, o de qualquer emoji será sempre 1.

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

unicodeScalars permanece inalterado no Swift 4. Ele fornece os caracteres Unicode exclusivos na string especificada.

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

2. Contém

No Swift 4.0, o containsmétodo ignora o marceneiro de largura zero em emoji. Portanto, ele retorna true para qualquer um dos quatro componentes emoji de "👩‍👩‍👧‍👦"e retorna false se você verificar o marceneiro. No entanto, no Swift 3.0, o marceneiro não é ignorado e é combinado com o emoji à sua frente. Portanto, quando você verifica se "👩‍👩‍👧‍👦"contém os três primeiros emojis componentes, o 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
Fangming
fonte
0

Emojis, assim como o padrão unicode, são enganosamente complicados. Tons de pele, sexo, trabalhos, grupos de pessoas, sequências de marcadores com largura zero, sinalizadores (unicode de 2 caracteres) e outras complicações podem tornar a análise de emoji confusa. Uma árvore de Natal, uma fatia de pizza ou uma pilha de cocô podem ser representadas com um único ponto de código Unicode. Sem mencionar que, quando novos emojis são introduzidos, há um atraso entre o suporte ao iOS e a liberação de emoji. Isso e o fato de que diferentes versões do iOS suportam versões diferentes do padrão unicode.

TL; DR. Eu trabalhei nesses recursos e abri uma biblioteca. Sou o autor do JKEmoji para ajudar a analisar as seqüências de caracteres com emojis. Torna a análise tão fácil quanto:

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

5

Ele faz isso atualizando rotineiramente um banco de dados local de todos os emojis reconhecidos na versão unicode mais recente ( 12.0 recentemente) e fazendo uma referência cruzada com o que é reconhecido como um emoji válido na versão do SO em execução, observando a representação de bitmap de um caractere emoji não reconhecido.

NOTA

Uma resposta anterior foi excluída por anunciar minha biblioteca sem afirmar claramente que sou o autor. Estou reconhecendo isso novamente.

Joe
fonte
2
Enquanto eu estou impressionado com a sua biblioteca, e vejo como é geralmente relacionado com o tema em questão, não vejo como isso se relaciona diretamente à questão
Ben leggiero