Comment puis-je effectuer une opération «commence par» sensible à la culture à partir du milieu d'une chaîne?

106

J'ai une exigence qui est relativement obscure, mais j'ai l'impression que cela devrait être possible en utilisant la BCL.

Pour le contexte, j'analyse une chaîne de date / heure dans Noda Time . Je maintiens un curseur logique pour ma position dans la chaîne d'entrée. Ainsi, alors que la chaîne complète peut être «3 janvier 2013», le curseur logique peut être sur le «J».

Maintenant, je dois analyser le nom du mois, en le comparant à tous les noms de mois connus pour la culture:

  • Sensible à la culture
  • Insensibilité à la casse
  • Juste à partir du point du curseur (pas plus tard; je veux voir si le curseur "regarde" le nom du mois candidat)
  • Rapidement
  • ... et j'ai besoin de savoir par la suite combien de caractères ont été utilisés

Le code actuel pour ce faire fonctionne généralement, en utilisant CompareInfo.Compare. C'est effectivement comme ça (juste pour la partie correspondante - il y a plus de code dans la vraie chose, mais ce n'est pas pertinent pour la correspondance):

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

Cependant, cela dépend du fait que le candidat et la région que nous comparons ont la même longueur. Bien la plupart du temps, mais pas très bien dans certains cas particuliers. Supposons que nous ayons quelque chose comme:

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

Maintenant, ma comparaison échouera. Je pourrais utiliser IsPrefix:

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

mais:

  • Cela m'oblige à créer une sous-chaîne, ce que je préfère éviter. (Je considère Noda Time comme une bibliothèque système; l'analyse des performances peut très bien être importante pour certains clients.)
  • Il ne me dit pas à quelle distance faire avancer le curseur par la suite

En réalité, je soupçonne fortement ce ne sera pas venu très souvent ... mais je voudrais vraiment que je fasse la bonne chose ici. J'aimerais aussi vraiment pouvoir le faire sans devenir un expert Unicode ou l'implémenter moi-même :)

(Élevé en tant que bogue 210 dans Noda Time, au cas où quelqu'un voudrait suivre une conclusion éventuelle.)

J'aime l'idée de normalisation. Je dois vérifier cela en détail pour a) l'exactitude et b) les performances. En supposant que je puisse le faire fonctionner correctement, je ne sais toujours pas comment cela vaudrait la peine d'être changé - c'est le genre de chose qui ne se produira probablement jamais dans la vraie vie, mais qui pourrait nuire aux performances de tous mes utilisateurs: (

J'ai également vérifié la BCL - qui ne semble pas non plus gérer cela correctement. Exemple de code:

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

Changer le nom du mois personnalisé en juste "lit" avec une valeur de texte "bEd" analyse très bien.

D'accord, quelques points de données supplémentaires:

  • Le coût d'utilisation Substringet IsPrefixest important mais pas horrible. Sur un échantillon de "Vendredi 12 avril 2013 20:28:42" sur mon ordinateur portable de développement, cela change le nombre d'opérations d'analyse que je peux exécuter en une seconde d'environ 460K à environ 400K. Je préfère éviter ce ralentissement si possible, mais ce n'est pas trop mal.

  • La normalisation est moins faisable que je ne le pensais - car elle n'est pas disponible dans les bibliothèques de classes portables. Je pourrais potentiellement l'utiliser uniquement pour les versions non PCL, ce qui permettrait aux versions PCL d'être un peu moins correctes. Le succès des tests de normalisation ( string.IsNormalized) réduit les performances à environ 445 000 appels par seconde, ce avec quoi je peux vivre. Je ne suis toujours pas sûr qu'il fasse tout ce dont j'ai besoin - par exemple, un nom de mois contenant "ß" devrait correspondre à "ss" dans de nombreuses cultures, je crois ... et la normalisation ne fait pas cela.

Jon Skeet
la source
Bien que je comprenne votre désir d'éviter les effets négatifs liés à la création d'une sous-chaîne, il serait peut-être préférable de le faire, mais plus tôt dans le jeu, en déplaçant tout d'abord vers une forme de normalisation Unicode choisie, puis en sachant que vous pouvez marcher "point par point ". Probablement forme D.
IDisposable le
@IDisposable: Oui, je me suis posé des questions à ce sujet. Évidemment, je peux normaliser les noms de mois eux-mêmes à l'avance. Au moins, je peux faire la normalisation une seule fois. Je me demande si la procédure de normalisation vérifie si quelque chose doit être fait en premier. Je n'ai pas beaucoup d'expérience en normalisation - certainement une piste à explorer.
Jon Skeet
1
Si votre textn'est pas trop long, vous pouvez le faire if (compareInfo.IndexOf(text, candidate, position, options) == position). msdn.microsoft.com/en-us/library/ms143031.aspx Mais si textc'est très long, cela va perdre beaucoup de temps à chercher au-delà de ce qu'il faut.
Jim Mischel
1
Il suffit de dérivation en utilisant la Stringclasse du tout dans ce cas et d' utiliser un Char[]directement. Vous finirez par écrire plus de code, mais c'est ce qui se passe lorsque vous voulez des performances élevées ... ou peut-être que vous devriez programmer en C ++ / CLI ;-)
intrepidis
1
CompareOptions.IgnoreNonSpace ne s'en chargera- t- il pas automatiquement pour vous? Il me semble (du docco, pas en mesure de tester à partir de cet iPad désolé!) Comme si cela pourrait être un ( le ?) Cas d'utilisation pour cette option. " Indique que la comparaison de chaînes doit ignorer les caractères de combinaison sans espacement, tels que les signes diacritiques. "
Sepster

Réponses:

41

Je vais d'abord considérer le problème de plusieurs <-> un / plusieurs casemappings et séparément de la gestion de différentes formes de normalisation.

Par exemple:

x heiße y
  ^--- cursor

Correspond heissemais déplace trop le curseur 1. Et:

x heisse y
  ^--- cursor

Correspond heißemais déplace le curseur 1 trop moins.

Cela s'appliquera à tout caractère qui n'a pas de simple mappage un-à-un.

Vous devez connaître la longueur de la sous-chaîne qui correspond réellement. Mais Compare, IndexOf..etc jeter ces informations. Cela pourrait être possible avec des expressions régulières, mais l'implémentation ne fait pas le pliage complet de la casse et ne correspond donc pas ßau ss/SSmode insensible à la casse même si .Compareet.IndexOf fait. De toute façon, il serait probablement coûteux de créer de nouvelles expressions régulières pour chaque candidat.

La solution la plus simple à cela est de simplement stocker en interne les chaînes sous forme pliée en cas et de faire des comparaisons binaires avec les candidats pliés en cas. Ensuite, vous pouvez déplacer le curseur correctement avec juste .Lengthpuisque le curseur est pour la représentation interne. Vous récupérez également la plupart des performances perdues en n'ayant pas à utiliserCompareOptions.IgnoreCase .

Malheureusement, il n'y a pas de fonction de pliage de cas intégrée et le pliage de cas du pauvre homme ne fonctionne pas non plus car il n'y a pas de mappage complet de cas - la ToUpperméthode ne se transforme pas ßenSS .

Par exemple, cela fonctionne en Java (et même en Javascript), avec une chaîne de caractères au format normal 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);
}

Amusant de noter que la comparaison de casse ignorée de Java ne fait pas de pliage complet de cas comme C # CompareOptions.IgnoreCase . Donc, ils sont opposés à cet égard: Java fait un casemapping complet, mais un simple pliage de cas - C # fait un simple casemapping, mais un pliage complet de cas.

Il est donc probable que vous ayez besoin d'une bibliothèque tierce pour plier vos chaînes avant de les utiliser.


Avant de faire quoi que ce soit, vous devez vous assurer que vos chaînes sont au format normal C. Vous pouvez utiliser cette vérification rapide préliminaire optimisée pour le script latin:

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

Cela donne de faux positifs mais pas de faux négatifs, je ne m'attends pas à ce qu'il ralentisse du tout 460k analyses / s lors de l'utilisation de caractères de script latin, même si cela doit être effectué sur chaque chaîne. Avec un faux positif, vous utiliseriezIsNormalized pour obtenir un vrai négatif / positif et seulement après cela normaliser si nécessaire.


Donc, en conclusion, le traitement consiste à assurer d'abord la forme normale C, puis le pli du boîtier. Faites des comparaisons binaires avec les chaînes traitées et déplacez le curseur au fur et à mesure que vous le déplacez.

Esailija
la source
Merci pour cela - je vais devoir examiner plus en détail la forme de normalisation C, mais ce sont d'excellents conseils. Je pense que je peux vivre avec le "cela ne fonctionne pas tout à fait correctement sous le PCL" (qui ne fournit pas de normalisation). Utiliser une bibliothèque tierce pour le pliage de cas serait excessif ici - nous n'avons actuellement aucune dépendance tierce, et en introduire une juste pour un cas d'angle que même la BCL ne gère pas serait pénible. Vraisemblablement, le pliage de la casse est sensible à la culture, btw (par exemple en turc)?
Jon Skeet
2
@JonSkeet oui, Turkic mérite son propre mode dans les mappages de dossiers: P Voir la section format dans l'en-tête de CaseFolding.txt
Esailija
Cette réponse semble avoir un défaut fondamental, en ce qu'elle implique que les caractères ne correspondent aux ligatures (et vice versa) que lors du pliage de la casse. Ce n'est pas le cas; il y a des ligatures qui sont considérées comme égales aux caractères indépendamment de l'enveloppe. Par exemple, dans la culture en-US, æest égal à aeet est égal à ffi. La normalisation C ne gère pas du tout les ligatures, car elle n'autorise que les mappages de compatibilité (qui sont généralement limités à la combinaison de caractères).
Douglas
La normalisation KC et KD gère certaines ligatures, telles que , mais en manque d'autres, telles que æ. Le problème est aggravé par les écarts entre les cultures - æest égal à aeunder en-US, mais pas sous da-DK, comme indiqué dans la documentation MSDN pour les chaînes . Ainsi, la normalisation (sous n'importe quelle forme) et le mappage de cas ne sont pas une solution suffisante pour ce problème.
Douglas
Petite correction à mon commentaire précédent: la normalisation C n'autorise que les mappages canoniques (comme pour combiner des caractères), pas les mappages de compatibilité (comme pour les ligatures).
Douglas
21

Vérifiez si cela répond à l'exigence.:

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.Comparene fonctionne qu'une fois sourcecommencé avec prefix; sinon, IsPrefixrevient -1; sinon, la longueur des caractères utilisée dans source.

Cependant, je ne sais pas , sauf augmentation length2par 1le cas suivant:

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

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

mise à jour :

J'ai essayé d'améliorer un peu les performances, mais il n'est pas prouvé qu'il y ait un bogue dans le code suivant:

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

J'ai testé avec le cas particulier, et la comparaison jusqu'à environ 3.

Ken Kin
la source
Je préfère vraiment ne pas avoir à boucler comme ça. Certes, avec le début, il n'aura besoin de faire une boucle que s'il trouve quelque chose, mais je préfère ne pas avoir à faire des comparaisons de 8 chaînes juste pour correspondre à "Février" par exemple. J'ai l'impression qu'il doit y avoir un meilleur moyen. En outre, l' IndexOfopération initiale doit parcourir toute la chaîne à partir de la position de départ, ce qui serait gênant pour les performances si la chaîne d'entrée est longue.
Jon Skeet
@JonSkeet: Merci. Peut-être qu'il y a quelque chose qui peut être ajouté pour détecter si la boucle peut être diminuée. Je vais y réfléchir.
Ken Kin
@JonSkeet: Envisageriez-vous d'utiliser la réflexion? Depuis que j'ai retracé les méthodes, elles tombent dans l'invocation de méthodes natives non loin.
Ken Kin
3
En effet. Noda Time ne veut pas entrer dans les détails Unicode :)
Jon Skeet
2
J'ai résolu un problème similaire une fois comme celui-ci (mise en évidence de la chaîne de recherche en HTML). Je l'ai fait de la même manière. Vous pouvez régler la boucle et la stratégie de recherche de manière à ce qu'elle se termine très rapidement en vérifiant d'abord les cas probables. La bonne chose à ce sujet est que cela semble être totalement correct et qu'aucun détail Unicode ne s'infiltre dans votre code.
usr
9

Ceci est en fait possible sans normalisation et sans utiliser IsPrefix .

Nous devons comparer le même nombre d' éléments de texte par opposition au même nombre de caractères, mais toujours renvoyer le nombre de caractères correspondants.

J'ai créé une copie de la MatchCaseInsensitiveméthode à partir de ValueCursor.cs dans Noda Time et je l'ai légèrement modifiée afin qu'elle puisse être utilisée dans un contexte statique:

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

(Juste inclus pour référence, c'est le code qui ne se comparera pas correctement comme vous le savez)

La variante suivante de cette méthode utilise StringInfo.GetNextTextElement qui est fourni par le framework. L'idée est de comparer élément de texte par élément de texte pour trouver une correspondance et, si trouvé, retourner le nombre réel de caractères correspondants dans la chaîne source:

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

Cette méthode fonctionne très bien au moins selon mes cas de test (qui testent simplement quelques variantes des chaînes que vous avez fournies: "b\u00e9d"et "be\u0301d").

Cependant, la méthode GetNextTextElement crée une sous-chaîne pour chaque élément de texte, de sorte que cette implémentation nécessite beaucoup de comparaisons de sous-chaînes - ce qui aura un impact sur les performances.

J'ai donc créé une autre variante qui n'utilise pas GetNextTextElement mais ignore à la place les caractères de combinaison Unicode pour trouver la longueur de correspondance réelle en caractères:

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

Cette méthode utilise les deux assistants suivants:

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

Je n'ai fait aucun benchmark, donc je ne sais pas vraiment si la méthode la plus rapide est réellement plus rapide. Je n'ai pas non plus fait de tests prolongés.

Mais cela devrait répondre à votre question sur la façon d'effectuer une correspondance de sous-chaînes sensibles à la culture pour les chaînes pouvant inclure des caractères de combinaison Unicode.

Voici les cas de test que j'ai utilisés:

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

Les valeurs de tuple sont:

  1. La chaîne source (meule de foin)
  2. La position de départ dans la source.
  3. La chaîne de correspondance (aiguille).
  4. La durée de match attendue.

L'exécution de ces tests sur les trois méthodes donne ce résultat:

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

Les deux derniers tests testent le cas où la chaîne source est plus courte que la chaîne de correspondance. Dans ce cas, la méthode originale (temps Noda) réussira également.

Mårten Wikström
la source
Merci beaucoup pour cela. Je vais devoir l'examiner en détail pour voir à quel point il fonctionne, mais cela ressemble à un excellent point de départ. Plus de connaissances d'Unicode (dans le code lui-même) que je ne l'aurais espéré , mais si la plate-forme ne fait pas ce qui est nécessaire, je ne peux pas faire grand-chose à ce sujet :(
Jon Skeet
@JonSkeet: Heureux de vous aider! Et oui, la correspondance des sous-chaînes avec le support Unicode aurait certainement dû être incluse dans le framework ...
Mårten Wikström