Como posso realizar uma operação "começa com" sensível à cultura no meio de uma string?

106

Tenho um requisito que é relativamente obscuro, mas parece que deveria ser possível usando o BCL.

Para contextualizar, estou analisando uma string de data / hora em Noda Time . Eu mantenho um cursor lógico para minha posição dentro da string de entrada. Portanto, embora a string completa possa ser "3 de janeiro de 2013", o cursor lógico pode estar em 'J'.

Agora, preciso analisar o nome do mês, comparando-o com todos os nomes de meses conhecidos da cultura:

  • Sensibilidade à cultura
  • Não faz distinção entre maiúsculas e minúsculas
  • Apenas do ponto do cursor (não mais tarde; quero ver se o cursor está "olhando" para o nome do mês candidato)
  • Rapidamente
  • ... e eu preciso saber depois quantos caracteres foram usados

O código atual para fazer isso geralmente funciona, usando CompareInfo.Compare. É efetivamente assim (apenas para a parte correspondente - há mais código na coisa real, mas não é relevante para a correspondência):

internal bool MatchCaseInsensitive(string candidate, CompareInfo compareInfo)
{
    return compareInfo.Compare(text, position, candidate.Length,
                               candidate, 0, candidate.Length, 
                               CompareOptions.IgnoreCase) == 0;
}

No entanto, isso depende do candidato e da região comparada ter o mesmo comprimento. Bom na maioria das vezes, mas não em alguns casos especiais. Suponha que tenhamos algo como:

// U+00E9 is a single code point for e-acute
var text = "x b\u00e9d y";
int position = 2;
// e followed by U+0301 still means e-acute, but from two code points
var candidate = "be\u0301d";

Agora minha comparação falhará. Eu poderia usar IsPrefix:

if (compareInfo.IsPrefix(text.Substring(position), candidate,
                         CompareOptions.IgnoreCase))

mas:

  • Isso exige que eu crie uma substring, o que realmente prefiro evitar. (Estou vendo o Noda Time como efetivamente uma biblioteca do sistema; o desempenho da análise pode ser importante para alguns clientes.)
  • Não me diz o quanto devo avançar o cursor depois

Na realidade, tenho fortes suspeitas de que isso não acontecerá com muita frequência ... mas eu realmente gostaria de fazer a coisa certa aqui. Também gostaria muito de poder fazer isso sem me tornar um especialista em Unicode ou implementá-lo sozinho :)

(Criado como bug 210 na Hora de Noda, caso alguém queira seguir alguma conclusão eventual.)

Gosto da ideia de normalização. Preciso verificar isso em detalhes para a) correção eb) desempenho. Supondo que posso fazer funcionar corretamente, ainda não tenho certeza se valeria a pena mudar tudo - é o tipo de coisa que provavelmente nunca aparecerá na vida real, mas pode prejudicar o desempenho de todos os meus usuários: (

Também verifiquei o BCL - que também não parece lidar com isso corretamente. Código de amostra:

using System;
using System.Globalization;

class Test
{
    static void Main()
    {
        var culture = (CultureInfo) CultureInfo.InvariantCulture.Clone();
        var months = culture.DateTimeFormat.AbbreviatedMonthNames;
        months[10] = "be\u0301d";
        culture.DateTimeFormat.AbbreviatedMonthNames = months;

        var text = "25 b\u00e9d 2013";
        var pattern = "dd MMM yyyy";
        DateTime result;
        if (DateTime.TryParseExact(text, pattern, culture,
                                   DateTimeStyles.None, out result))
        {
            Console.WriteLine("Parsed! Result={0}", result);
        }
        else
        {
            Console.WriteLine("Didn't parse");
        }
    }
}

Alterar o nome do mês personalizado para apenas "cama" com um valor de texto "bEd" tem uma boa análise.

Ok, mais alguns pontos de dados:

  • O custo de uso Substringe IsPrefixé significativo, mas não horrível. Em uma amostra de "Sexta-feira, 12 de abril de 2013 20:28:42" no meu laptop de desenvolvimento, ele altera o número de operações de análise que posso executar em um segundo de cerca de 460K para cerca de 400K. Prefiro evitar essa desaceleração, se possível, mas não é tão ruim.

  • A normalização é menos viável do que eu pensava - porque não está disponível nas Bibliotecas de Classes Portáteis. Eu poderia usá-lo potencialmente apenas para compilações não PCL, permitindo que as compilações PCL fossem um pouco menos corretas. O impacto no desempenho do teste de normalização ( string.IsNormalized) reduz o desempenho para cerca de 445 mil chamadas por segundo, com o qual posso viver. Ainda não tenho certeza se ele faz tudo o que preciso - por exemplo, um nome de mês contendo "ß" deve corresponder a "ss" em muitas culturas, eu acredito ... e normalizar não faz isso.

Jon Skeet
fonte
Embora eu entenda seu desejo de evitar o impacto no desempenho da criação de uma substring, pode ser melhor fazer isso, mas no início do jogo, mudando tudo para uma forma de normalização unicode escolhida PRIMEIRO e sabendo que você pode caminhar "ponto a ponto " Provavelmente em forma D.
IDisponivel
@IDisposable: Sim, eu me perguntei sobre isso. Obviamente, posso normalizar os próprios nomes dos meses de antemão. Posso fazer a normalização pelo menos uma vez. Eu me pergunto se o procedimento de normalização verifica se algo precisa ser feito primeiro. Eu não tenho muita experiência em normalização - definitivamente, um caminho a seguir.
Jon Skeet de
1
Se textnão for muito longo, você pode fazer if (compareInfo.IndexOf(text, candidate, position, options) == position). msdn.microsoft.com/en-us/library/ms143031.aspx Mas se textfor muito longo, vai perder muito tempo pesquisando além de onde é necessário.
Jim Mischel,
1
Apenas desvio utilizando a Stringclasse em tudo neste caso e usar um Char[]diretamente. Você acabará escrevendo mais código, mas é o que acontece quando você quer alto desempenho ... ou talvez você devesse estar programando em C ++ / CLI ;-)
intrepidis
1
O CompareOptions.IgnoreNonSpace não cuidará disso automaticamente para você? Parece-me (a partir do docco, não estou em posição de testar neste iPad, desculpe!) Que este pode ser um ( o ?) Caso de uso para essa opção. " Indica que a comparação de strings deve ignorar caracteres de combinação sem espaçamento, como diacríticos. "
Sepster

Respostas:

41

Vou considerar o problema de muitos <-> um / muitos mapeamentos de caso primeiro e separadamente do tratamento de diferentes formulários de normalização.

Por exemplo:

x heiße y
  ^--- cursor

Corresponde, heissemas move o cursor 1 demais. E:

x heisse y
  ^--- cursor

Corresponde, heißemas move o cursor 1 muito menos.

Isso se aplicará a qualquer caractere que não tenha um mapeamento um-para-um simples.

Você precisaria saber o comprimento da substring que foi realmente correspondida. Mas Compare, IndexOf.etc jogar essa informação de distância. Poderia ser possível com expressões regulares, mas a implementação não faz a dobragem completa de maiúsculas e minúsculas e, portanto, não corresponde ßao ss/SSmodo não sensível a maiúsculas, embora .Comparee .IndexOffaça. E provavelmente seria caro criar novas regexes para cada candidato de qualquer maneira.

A solução mais simples para isso é apenas armazenar internamente as strings em formato case-fold e fazer comparações binárias com os candidatos case-fold. Em seguida, você pode mover o cursor corretamente com apenas .Lengthuma vez que o cursor é para representação interna. Você também obtém a maior parte do desempenho perdido por não ter que usar CompareOptions.IgnoreCase.

Infelizmente, não há função de dobra de caixa embutida e a dobra de caixa do pobre homem também não funciona porque não há mapeamento completo de caixa - o ToUppermétodo não se transforma ßem SS.

Por exemplo, isso funciona em Java (e mesmo em Javascript), dada a string que está no Formulário C normal:

//Poor man's case folding.
//There are some edge cases where this doesn't work
public static String toCaseFold( String input, Locale cultureInfo ) {
    return input.toUpperCase(cultureInfo).toLowerCase(cultureInfo);
}

É divertido notar que a comparação de ignorar maiúsculas e minúsculas do Java não faz a dobra completa de maiúsculas e minúsculas como o C # CompareOptions.IgnoreCase. Portanto, eles são opostos a esse respeito: Java faz casemapping completo, mas simples casemapping - C # faz casemapping simples, mas full case fold.

Portanto, é provável que você precise de uma biblioteca de terceiros para dobrar a caixa de suas strings antes de usá-las.


Antes de fazer qualquer coisa, você deve ter certeza de que suas strings estão na forma normal C. Você pode usar esta verificação rápida preliminar otimizada para escrita latina:

public static bool MaybeRequiresNormalizationToFormC(string input)
{
    if( input == null ) throw new ArgumentNullException("input");

    int len = input.Length;
    for (int i = 0; i < len; ++i)
    {
        if (input[i] > 0x2FF)
        {
            return true;
        }
    }

    return false;
}

Isso fornece falsos positivos, mas não falsos negativos. Não espero que diminua a velocidade de 460 mil análises / s ao usar caracteres de script latino, embora precise ser executado em todas as strings. Com um falso positivo você usaria IsNormalizedpara obter um verdadeiro negativo / positivo e só depois normalizar se necessário.


Portanto, em conclusão, o processamento é para garantir a forma normal C primeiro, depois a dobra de caixa. Faça comparações binárias com as strings processadas e mova o cursor conforme você o move atualmente.

Esailija
fonte
Obrigado por isso - vou precisar examinar o formulário C de normalização com mais detalhes, mas essas são ótimas dicas. Acho que posso conviver com o "não funciona muito bem no PCL" (que não fornece normalização). Usar uma biblioteca de terceiros para dobrar o caso seria um exagero aqui - atualmente não temos dependências de terceiros, e apresentar uma apenas para um caso que nem mesmo o BCL lida seria uma dor. Presumivelmente, a dobragem de maiúsculas e minúsculas é sensível à cultura, btw (por exemplo, turco)?
Jon Skeet de
2
@JonSkeet sim, o turco merece seu próprio modo nos mapeamentos casefold: P Veja a seção de formato no cabeçalho de CaseFolding.txt
Esailija
Essa resposta parece ter uma falha fundamental, já que implica que os caracteres mapeiam para ligaduras (e vice-versa) apenas ao dobrar a caixa. Este não é o caso; existem ligaduras que são consideradas iguais aos caracteres, independentemente do revestimento. Por exemplo, na cultura en-US, æé igual a aee é igual a ffi. A normalização C não lida com ligaduras, uma vez que permite apenas mapeamentos de compatibilidade (que normalmente são restritos à combinação de caracteres).
Douglas
A normalização KC e KD lida com algumas ligaduras, como , mas deixa de ver outras, como æ. O problema é agravado pelas discrepâncias entre culturas - æé igual a aeem en-US, mas não em da-DK, conforme discutido na documentação do MSDN para strings . Portanto, a normalização (para qualquer forma) e o mapeamento de caso não são uma solução suficiente para este problema.
Douglas
Uma pequena correção no meu comentário anterior: a normalização C permite apenas mapeamentos canônicos (como para combinar caracteres), não mapeamentos de compatibilidade (como para ligaduras).
Douglas
21

Veja se isso atende ao requisito ..:

public static partial class GlobalizationExtensions {
    public static int IsPrefix(
        this CompareInfo compareInfo,
        String source, String prefix, int startIndex, CompareOptions options
        ) {
        if(compareInfo.IndexOf(source, prefix, startIndex, options)!=startIndex)
            return ~0;
        else
            // source is started with prefix
            // therefore the loop must exit
            for(int length2=0, length1=prefix.Length; ; )
                if(0==compareInfo.Compare(
                        prefix, 0, length1, 
                        source, startIndex, ++length2, options))
                    return length2;
    }
}

compareInfo.Compareexecuta apenas uma vez sourceiniciado com prefix; se não, então IsPrefixretorna -1; caso contrário, o comprimento dos caracteres usados ​​em source.

No entanto, eu não tenho idéia, exceto incremento de length2pelo 1com o seguinte caso:

var candidate="ßssß\u00E9\u0302";
var text="abcd ssßss\u0065\u0301\u0302sss";

var count=
    culture.CompareInfo.IsPrefix(text, candidate, 5, CompareOptions.IgnoreCase);

atualização :

Tentei melhorar um pouco o desempenho, mas não está provado se há bug no código a seguir:

public static partial class GlobalizationExtensions {
    public static int Compare(
        this CompareInfo compareInfo,
        String source, String prefix, int startIndex, ref int length2, 
        CompareOptions options) {
        int length1=prefix.Length, v2, v1;

        if(0==(v1=compareInfo.Compare(
            prefix, 0, length1, source, startIndex, length2, options))
            ) {
            return 0;
        }
        else {
            if(0==(v2=compareInfo.Compare(
                prefix, 0, length1, source, startIndex, 1+length2, options))
                ) {
                ++length2;
                return 0;
            }
            else {
                if(v1<0||v2<0) {
                    length2-=2;
                    return -1;
                }
                else {
                    length2+=2;
                    return 1;
                }
            }
        }
    }

    public static int IsPrefix(
        this CompareInfo compareInfo,
        String source, String prefix, int startIndex, CompareOptions options
        ) {
        if(compareInfo.IndexOf(source, prefix, startIndex, options)
                !=startIndex)
            return ~0;
        else
            for(int length2=
                    Math.Min(prefix.Length, source.Length-(1+startIndex)); ; )
                if(0==compareInfo.Compare(
                        source, prefix, startIndex, ref length2, options))
                    return length2;
    }
}

Eu testei com o caso específico e a comparação caiu para cerca de 3.

Ken Kin
fonte
Eu realmente prefiro não ter que fazer um loop assim. Admito que com o início ele só precisará fazer um loop se for encontrado algo, mas ainda prefiro não ter que fazer 8 comparações de strings apenas para corresponder a "fevereiro", por exemplo. Parece que deve haver uma maneira melhor. Além disso, a IndexOfoperação inicial deve examinar toda a string a partir da posição inicial, o que seria um problema de desempenho se a string de entrada fosse longa.
Jon Skeet de
@JonSkeet: Obrigado. Talvez haja algo que possa ser adicionado para detectar se o loop pode ser diminuído. Vou pensar nisso.
Ken Kin
@JonSkeet: Você consideraria usar reflexão? Desde que rastreei os métodos, eles recorrem a métodos nativos não muito longe.
Ken Kin
3
De fato. Noda Time não quer entrar no negócio de detalhes Unicode :)
Jon Skeet
2
Resolvi um problema semelhante uma vez como este (destaque de string de pesquisa em HTML). Eu fiz de forma semelhante. Você pode ajustar o loop e a estratégia de pesquisa de uma forma que o torne concluído muito rapidamente, verificando primeiro os casos prováveis. O bom disso é que parece estar totalmente correto e nenhum detalhe Unicode vaza para o seu código.
usr
9

Na verdade, isso é possível sem normalização e sem uso IsPrefix.

Precisamos comparar o mesmo número de elementos de texto em oposição ao mesmo número de caracteres, mas ainda retornar o número de caracteres correspondentes.

Eu criei uma cópia do MatchCaseInsensitivemétodo de ValueCursor.cs em Noda Time e modifiquei um pouco para que possa ser usado em um contexto estático:

// Noda time code from MatchCaseInsensitive in ValueCursor.cs
static int IsMatch_Original(string source, int index, string match, CompareInfo compareInfo)
{
    unchecked
    {
        if (match.Length > source.Length - index)
        {
            return 0;
        }

        // TODO(V1.2): This will fail if the length in the input string is different to the length in the
        // match string for culture-specific reasons. It's not clear how to handle that...
        if (compareInfo.Compare(source, index, match.Length, match, 0, match.Length, CompareOptions.IgnoreCase) == 0)
        {
            return match.Length;
        }

        return 0;
    }
}

(Incluído apenas para referência, é o código que não será comparado corretamente como você sabe)

A seguinte variante desse método usa StringInfo.GetNextTextElement que é fornecido pela estrutura. A ideia é comparar elemento de texto por elemento de texto para encontrar uma correspondência e, se encontrado, retornar o número real de caracteres correspondentes na string de origem:

// Using StringInfo.GetNextTextElement to match by text elements instead of characters
static int IsMatch_New(string source, int index, string match, CompareInfo compareInfo)
{
    int sourceIndex = index;
    int matchIndex = 0;

    // Loop until we reach the end of source or match
    while (sourceIndex < source.Length && matchIndex < match.Length)
    {
        // Get text elements at the current positions of source and match
        // Normally that will be just one character but may be more in case of Unicode combining characters
        string sourceElem = StringInfo.GetNextTextElement(source, sourceIndex);
        string matchElem = StringInfo.GetNextTextElement(match, matchIndex);

        // Compare the current elements.
        if (compareInfo.Compare(sourceElem, matchElem, CompareOptions.IgnoreCase) != 0)
        {
            return 0; // No match
        }

        // Advance in source and match (by number of characters)
        sourceIndex += sourceElem.Length;
        matchIndex += matchElem.Length;
    }

    // Check if we reached end of source and not end of match
    if (matchIndex != match.Length)
    {
        return 0; // No match
    }

    // Found match. Return number of matching characters from source.
    return sourceIndex - index;
}

Esse método funciona bem, pelo menos de acordo com meus casos de teste (que basicamente testam algumas variantes das strings que você forneceu: "b\u00e9d"e "be\u0301d").

No entanto, o método GetNextTextElement cria uma substring para cada elemento de texto, portanto, essa implementação requer muitas comparações de substring - o que terá um impacto no desempenho.

Portanto, criei outra variante que não usa GetNextTextElement, mas, em vez disso, pula os caracteres de combinação Unicode para encontrar o comprimento de correspondência real em caracteres:

// This should be faster
static int IsMatch_Faster(string source, int index, string match, CompareInfo compareInfo)
{
    int sourceLength = source.Length;
    int matchLength = match.Length;
    int sourceIndex = index;
    int matchIndex = 0;

    // Loop until we reach the end of source or match
    while (sourceIndex < sourceLength && matchIndex < matchLength)
    {
        sourceIndex += GetTextElemLen(source, sourceIndex, sourceLength);
        matchIndex += GetTextElemLen(match, matchIndex, matchLength);
    }

    // Check if we reached end of source and not end of match
    if (matchIndex != matchLength)
    {
        return 0; // No match
    }

    // Check if we've found a match
    if (compareInfo.Compare(source, index, sourceIndex - index, match, 0, matchIndex, CompareOptions.IgnoreCase) != 0)
    {
        return 0; // No match
    }

    // Found match. Return number of matching characters from source.
    return sourceIndex - index;
}

Esse método usa os seguintes dois auxiliares:

static int GetTextElemLen(string str, int index, int strLen)
{
    bool stop = false;
    int elemLen;

    for (elemLen = 0; index < strLen && !stop; ++elemLen, ++index)
    {
        stop = !IsCombiningCharacter(str, index);
    }

    return elemLen;
}

static bool IsCombiningCharacter(string str, int index)
{
    switch (CharUnicodeInfo.GetUnicodeCategory(str, index))
    {
        case UnicodeCategory.NonSpacingMark:
        case UnicodeCategory.SpacingCombiningMark:
        case UnicodeCategory.EnclosingMark:
            return true;

        default:
            return false;
    }
}

Eu não fiz nenhuma marcação de banco, então eu realmente não sei se o método mais rápido é realmente mais rápido. Nem fiz nenhum teste extenso.

Mas isso deve responder à sua pergunta sobre como realizar a correspondência de substring com sensibilidade cultural para strings que podem incluir caracteres de combinação Unicode.

Estes são os casos de teste que usei:

static Tuple<string, int, string, int>[] tests = new []
{
    Tuple.Create("x b\u00e9d y", 2, "be\u0301d", 3),
    Tuple.Create("x be\u0301d y", 2, "b\u00e9d", 4),

    Tuple.Create("x b\u00e9d", 2, "be\u0301d", 3),
    Tuple.Create("x be\u0301d", 2, "b\u00e9d", 4),

    Tuple.Create("b\u00e9d y", 0, "be\u0301d", 3),
    Tuple.Create("be\u0301d y", 0, "b\u00e9d", 4),

    Tuple.Create("b\u00e9d", 0, "be\u0301d", 3),
    Tuple.Create("be\u0301d", 0, "b\u00e9d", 4),

    Tuple.Create("b\u00e9", 0, "be\u0301d", 0),
    Tuple.Create("be\u0301", 0, "b\u00e9d", 0),
};

Os valores da tupla são:

  1. A string de origem (palheiro)
  2. A posição inicial na fonte.
  3. O barbante de correspondência (agulha).
  4. O comprimento de correspondência esperado.

Executar esses testes nos três métodos produz este resultado:

Test #0: Orignal=BAD; New=OK; Faster=OK
Test #1: Orignal=BAD; New=OK; Faster=OK
Test #2: Orignal=BAD; New=OK; Faster=OK
Test #3: Orignal=BAD; New=OK; Faster=OK
Test #4: Orignal=BAD; New=OK; Faster=OK
Test #5: Orignal=BAD; New=OK; Faster=OK
Test #6: Orignal=BAD; New=OK; Faster=OK
Test #7: Orignal=BAD; New=OK; Faster=OK
Test #8: Orignal=OK; New=OK; Faster=OK
Test #9: Orignal=OK; New=OK; Faster=OK

Os dois últimos testes estão testando o caso em que a string de origem é mais curta do que a string de correspondência. Nesse caso, o método original (hora Noda) também será bem-sucedido.

Mårten Wikström
fonte
Muito obrigado por isso. Precisarei examiná-lo em detalhes para ver como ele funciona bem, mas parece um ótimo ponto de partida. Mais conhecimento de Unicode (no próprio código) do que eu esperava que fosse necessário, mas se a plataforma não fizer o que é necessário, não há muito que eu possa fazer sobre isso :(
Jon Skeet
@JonSkeet: Fico feliz em ajudar! E sim, a correspondência de substring com suporte a Unicode definitivamente deveria ter sido incluída na estrutura ...
Mårten Wikström