Come posso eseguire un'operazione "inizia con" sensibile alle impostazioni cultura dalla metà di una stringa?


106

Ho un requisito relativamente oscuro, ma sembra che dovrebbe essere possibile utilizzare il BCL.

Per contesto, sto analizzando una stringa data / ora in Noda Time . Mantengo un cursore logico per la mia posizione all'interno della stringa di input. Quindi, sebbene la stringa completa possa essere "3 gennaio 2013", il cursore logico potrebbe trovarsi sulla "J".

Ora, ho bisogno di analizzare il nome del mese, confrontandolo con tutti i nomi dei mesi noti per la cultura:

  • Cultura-sensibile
  • Case-insensitively
  • Solo dal punto del cursore (non più tardi; voglio vedere se il cursore sta "guardando" il nome del mese candidato)
  • Velocemente
  • ... e poi ho bisogno di sapere quanti caratteri sono stati usati

Il codice corrente per farlo generalmente funziona, usando CompareInfo.Compare. È effettivamente così (solo per la parte corrispondente - c'è più codice nella cosa reale, ma non è rilevante per la corrispondenza):

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

Tuttavia, ciò dipende dal fatto che il candidato e la regione che confrontiamo abbiano la stessa lunghezza. Va bene per la maggior parte del tempo, ma non va bene in alcuni casi speciali. Supponiamo di avere qualcosa come:

// 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";

Ora il mio confronto fallirà. Potrei usare IsPrefix:

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

ma:

  • Ciò mi richiede di creare una sottostringa, che preferirei davvero evitare. (Sto vedendo Noda Time come una libreria di sistema efficace; l'analisi delle prestazioni potrebbe essere importante per alcuni client.)
  • Non mi dice di quanto far avanzare il cursore in seguito

In realtà, ho il forte sospetto che non si presenti molto spesso ... ma mi piacerebbe davvero come faccia la cosa giusta qui. Mi piacerebbe anche molto poterlo fare senza diventare un esperto di Unicode o implementarlo da solo :)

(Generato come bug 210 in Noda Time, nel caso qualcuno volesse seguire un'eventuale conclusione.)

Mi piace l'idea della normalizzazione. Devo verificarlo in dettaglio per a) correttezza eb) prestazioni. Supponendo che io possa farlo funzionare correttamente, non sono ancora sicuro di come valga la pena cambiare tutto - è il genere di cose che probabilmente non si presenteranno mai nella vita reale, ma potrebbero danneggiare le prestazioni di tutti i miei utenti: (

Ho anche controllato il BCL, che non sembra gestirlo correttamente. Codice di esempio:

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");
        }
    }
}

La modifica del nome del mese personalizzato in "letto" con un valore di testo "bEd" viene analizzata correttamente.

Ok, alcuni altri punti dati:

  • Il costo di utilizzo Substringed IsPrefixè significativo ma non orribile. In un esempio di "Friday April 12 2013 20:28:42" sul mio laptop di sviluppo, cambia il numero di operazioni di analisi che posso eseguire in un secondo da circa 460K a circa 400K. Preferirei evitare quel rallentamento se possibile, ma non è poi così male.

  • La normalizzazione è meno fattibile di quanto pensassi, perché non è disponibile nelle librerie di classi portatili. Potrei potenzialmente usarlo solo per build non PCL, consentendo alle build PCL di essere un po 'meno corrette. Il colpo di prestazioni del test per la normalizzazione ( string.IsNormalized) porta le prestazioni a circa 445K chiamate al secondo, con cui posso convivere. Non sono ancora sicuro che faccia tutto ciò di cui ho bisogno - per esempio, il nome di un mese contenente "ß" dovrebbe corrispondere a "ss" in molte culture, credo ... e la normalizzazione non lo fa.


Sebbene comprenda il tuo desiderio di evitare il calo delle prestazioni della creazione di una sottostringa, potrebbe essere meglio farlo, ma all'inizio del gioco spostando PRIMA tutto in un modulo di normalizzazione Unicode scelto e poi sapendo che puoi camminare "punto per punto ". Probabilmente forma D.
IDisponibile il

@ IDisposable: Sì, me lo chiedevo. Ovviamente posso normalizzare in anticipo i nomi dei mesi stessi. Almeno posso fare la normalizzazione solo una volta. Mi chiedo se la procedura di normalizzazione controlli se prima sia necessario fare qualcosa. Non ho molta esperienza nella normalizzazione, sicuramente una strada da esaminare.
Jon Skeet

1
Se il tuo textnon è troppo lungo, potresti farlo if (compareInfo.IndexOf(text, candidate, position, options) == position). msdn.microsoft.com/en-us/library/ms143031.aspx Ma se textè molto lungo, sprecherà molto tempo a cercare oltre il punto necessario.
Jim Mischel

1
Basta bypass utilizzando la Stringclasse a tutti , in questo caso e l'uso di unChar[] direttamente. Finirai per scrivere più codice, ma è quello che succede quando vuoi alte prestazioni ... o forse dovresti programmare in C ++ / CLI ;-)
intrepidis

1
Sarà CompareOptions.IgnoreNonSpace non prendersi cura di questo automaticamente per voi? Mi sembra (dal docco, non in grado di testare da questo iPad scusate!) Come se questo potrebbe essere un ( il ?) Caso d'uso per quell'opzione. " Indica che il confronto tra stringhe deve ignorare i caratteri che non combinano spazi, come i segni diacritici. "
segni

Risposte:


41

Prenderò in considerazione il problema di molte <-> una / molte casemapping prima e separatamente dalla gestione di diverse forme di normalizzazione.

Per esempio:

x heiße y
  ^--- cursor

Corrisponde heissema poi sposta troppo il cursore 1. E:

x heisse y
  ^--- cursor

Corrisponde heißema poi sposta il cursore 1 troppo di meno.

Questo si applicherà a qualsiasi personaggio che non abbia una semplice mappatura uno a uno.

Avresti bisogno di conoscere la lunghezza della sottostringa che è stata effettivamente abbinata. Ma ... ecc Compare. IndexOfButta via quelle informazioni. Potrebbe essere possibile con le espressioni regolari, ma l'implementazione non esegue la piegatura completa del caso e quindi non corrisponde ßass/SS in modalità case-insensitive anche se .Comparee lo .IndexOffanno. E sarebbe probabilmente costoso creare comunque nuove regex per ogni candidato.

La soluzione più semplice è memorizzare internamente le stringhe nel formato case folded e fare confronti binari con i candidati case folded. Quindi puoi spostare il cursore correttamente con just.Length perché il cursore è per la rappresentazione interna. Puoi anche recuperare la maggior parte delle prestazioni perse dal non doverlo utilizzare CompareOptions.IgnoreCase.

Sfortunatamente non è incorporata la funzione di piegatura della custodia e nemmeno la piegatura della custodia del povero funziona perché non esiste una mappatura completa del ToUpper metodo non si trasforma ßin SS.

Ad esempio questo funziona in Java (e anche in Javascript), data la stringa che è in forma normale C:

//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);
}

È divertente notare che il confronto tra maiuscole e minuscole di Java non fa piegare il caso completo come quello di C # CompareOptions.IgnoreCase . Quindi sono opposti a questo proposito: Java esegue la mappatura completa del caso, ma semplice piegatura del caso - C # fa semplice mappatura del caso, ma piegatura completa del caso.

Quindi è probabile che tu abbia bisogno di una libreria di terze parti per piegare le corde prima di usarle.


Prima di fare qualsiasi cosa devi assicurarti che le tue stringhe siano nella forma normale C. Puoi usare questo controllo rapido preliminare ottimizzato per la scrittura 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;
}

Questo dà falsi positivi ma non falsi negativi, non mi aspetto che rallenti per niente 460k analisi / s quando si usano caratteri latini anche se deve essere eseguito su ogni stringa. Con un falso positivo useresti IsNormalizedper ottenere un vero negativo / positivo e solo dopo normalizzalo se necessario.


Quindi, in conclusione, l'elaborazione deve garantire prima la forma normale C, quindi la piegatura del caso. Esegui confronti binari con le stringhe elaborate e sposta il cursore mentre lo stai spostando attualmente.


Grazie per questo: dovrò esaminare il modulo di normalizzazione C in modo più dettagliato, ma questi sono ottimi suggerimenti. Penso di poter convivere con il "non funziona abbastanza correttamente sotto il PCL" (che non fornisce la normalizzazione). L'utilizzo di una libreria di terze parti per la piegatura dei casi sarebbe eccessivo qui: al momento non abbiamo dipendenze di terze parti e introdurne una solo per un caso d'angolo che anche il BCL non gestisce sarebbe un problema. Presumibilmente la piegatura tra maiuscole e minuscole è sensibile alle impostazioni cultura, btw (eg turco)?
Jon Skeet

2
@JonSkeet sì, il turco merita la sua modalità nelle mappature del casefold: P Vedi la sezione del formato nell'intestazione di CaseFolding.txt
Esailija

Questa risposta sembra avere un difetto fondamentale, in quanto implica che i caratteri si mappino alle legature (e viceversa) solo quando si ripiegano i casi. Questo non è il caso; ci sono legature che sono considerate uguali ai caratteri indipendentemente dal case. Ad esempio, nelle impostazioni cultura en-US, æè uguale a aeed è uguale a ffi. La normalizzazione C non gestisce affatto le legature, poiché consente solo le mappature di compatibilità (che sono tipicamente limitate alla combinazione di caratteri).
Douglas

La normalizzazione KC e KD gestiscono alcune legature, come , ma ne mancano altre, come æ. Il problema è aggravato dalle discrepanze tra le culture: æè uguale a aeunder en-US, ma non a da-DK, come discusso nella documentazione MSDN per le stringhe . Pertanto, la normalizzazione (in qualsiasi forma) e la mappatura dei casi non sono una soluzione sufficiente per questo problema.
Douglas

Piccola correzione al mio commento precedente: la normalizzazione C consente solo mappature canoniche (come per la combinazione di caratteri), non mappature di compatibilità (come per le legature).
Douglas

21

Verifica se questo soddisfa il 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.Compareesegue solo una volta sourceiniziato con prefix; in caso contrario, IsPrefixritorna -1; in caso contrario, la lunghezza dei caratteri utilizzati in source.

Tuttavia, non ho idea se non incrementare length2di 1con il seguente caso:

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

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

aggiornamento :

Ho provato a migliorare un po 'le prestazioni, ma non è stato dimostrato se ci sia un bug nel codice seguente:

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;
    }
}

Ho provato con il caso particolare e il confronto è sceso a circa 3.


Preferirei davvero non dover ripetere il ciclo in questo modo. Certo, con l'uscita anticipata sarà necessario eseguire un ciclo solo se viene trovato qualcosa, ma preferirei comunque non dover fare confronti a 8 stringhe solo per abbinare "febbraio", ad esempio. Sembra che ci debba essere un modo migliore. Inoltre, l' IndexOfoperazione iniziale deve esaminare l'intera stringa dalla posizione iniziale, il che sarebbe un problema per le prestazioni se la stringa di input è lunga.
Jon Skeet

@ JonSkeet: grazie. Forse è possibile aggiungere qualcosa per rilevare se il ciclo può essere ridotto. Ci penserò io.
Ken Kin

@ JonSkeet: considereresti di utilizzare la riflessione? Dal momento che ho tracciato nei metodi, non sono riusciti a richiamare metodi nativi.
Ken Kin

3
Infatti. Noda Time non vuole entrare nel business dei dettagli Unicode :)
Jon Skeet

2
Ho risolto un problema simile una volta come questo (evidenziazione della stringa di ricerca in HTML). L'ho fatto allo stesso modo. Puoi regolare il loop e la strategia di ricerca in modo da completarli molto rapidamente controllando prima i casi probabili. La cosa bella di questo è che sembra essere totalmente corretto e nessun dettaglio Unicode trapela nel tuo codice.
usr

9

Questo è effettivamente possibile senza normalizzazione e senza utilizzare IsPrefix.

Dobbiamo confrontare lo stesso numero di elementi di testo rispetto allo stesso numero di caratteri, ma restituire comunque il numero di caratteri corrispondenti.

Ho creato una copia del MatchCaseInsensitivemetodo da ValueCursor.cs in Noda Time e l'ho leggermente modificato in modo che possa essere utilizzato in un contesto statico:

// 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;
    }
}

(Incluso solo come riferimento, è il codice che non verrà confrontato correttamente come sai)

La seguente variante di tale metodo utilizza StringInfo.GetNextTextElement fornito dal framework. L'idea è di confrontare elemento di testo per elemento di testo per trovare una corrispondenza e, se trovato, restituire il numero effettivo di caratteri corrispondenti nella stringa di origine:

// 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;
}

Quel metodo funziona bene almeno secondo i miei casi di test (che fondamentalmente testano solo un paio di varianti delle stringhe che hai fornito: "b\u00e9d"e "be\u0301d").

Tuttavia, il metodo GetNextTextElement crea una sottostringa per ogni elemento di testo, quindi questa implementazione richiede molti confronti di sottostringa, che avranno un impatto sulle prestazioni.

Quindi, ho creato un'altra variante che non utilizza GetNextTextElement ma invece salta Unicode che combina i caratteri per trovare la lunghezza della corrispondenza effettiva in caratteri:

// 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;
}

Questo metodo utilizza i seguenti due helper:

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;
    }
}

Non ho fatto alcun segno di panchina, quindi non so se il metodo più veloce sia effettivamente più veloce. Né ho eseguito test estesi.

Ma questo dovrebbe rispondere alla tua domanda su come eseguire la corrispondenza di sottostringhe culturali sensibili per stringhe che possono includere caratteri Unicode che combinano.

Questi sono i casi di test che ho usato:

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),
};

I valori della tupla sono:

  1. La stringa di origine (pagliaio)
  2. La posizione di partenza nella sorgente.
  3. La stringa di corrispondenza (ago).
  4. La durata prevista della partita.

L'esecuzione di questi test sui tre metodi produce questo risultato:

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

Gli ultimi due test stanno verificando il caso in cui la stringa di origine è più corta della stringa di corrispondenza. In questo caso anche il metodo originale (tempo Noda) avrà successo.


Grazie mille per questo. Avrò bisogno di esaminarlo in dettaglio per vedere come funziona, ma sembra un ottimo punto di partenza. Più conoscenza di Unicode (nel codice stesso) di quanto speravo fosse richiesta, ma se la piattaforma non fa ciò che è richiesto, non c'è molto che io possa fare al riguardo :(
Jon Skeet

@ JonSkeet: Felice di essere di aiuto! E sì, la corrispondenza della sottostringa con il supporto Unicode avrebbe dovuto essere inclusa definitivamente nel framework ...
Mårten Wikström
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.