¿Cómo puedo realizar una operación "empieza con" sensible a la cultura desde el medio de una cadena?

106

Tengo un requisito que es relativamente oscuro, pero parece que debería ser posible usando el BCL.

Para el contexto, estoy analizando una cadena de fecha / hora en Noda Time . Mantengo un cursor lógico para mi posición dentro de la cadena de entrada. Entonces, mientras que la cadena completa puede ser "3 de enero de 2013", el cursor lógico puede estar en la 'J'.

Ahora, necesito analizar el nombre del mes, comparándolo con todos los nombres de meses conocidos para la cultura:

  • Sensible a la cultura
  • No distingue entre mayúsculas y minúsculas
  • Solo desde el punto del cursor (no más tarde; quiero ver si el cursor está "mirando" el nombre del mes candidato)
  • Con rapidez
  • ... y luego necesito saber cuántos caracteres se usaron

El código actual para hacer esto generalmente funciona, usando CompareInfo.Compare. Es efectivamente así (solo para la parte de coincidencia; hay más código en la realidad, pero no es relevante para la coincidencia):

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

Sin embargo, eso depende de que el candidato y la región que comparamos tengan la misma longitud. Bien la mayor parte del tiempo, pero no bien en algunos casos especiales. Supongamos que tenemos 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";

Ahora mi comparación fallará. Podría usar IsPrefix:

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

pero:

  • Eso requiere que cree una subcadena, que realmente prefiero evitar. (Veo Noda Time efectivamente como una biblioteca del sistema; el rendimiento del análisis puede ser importante para algunos clientes).
  • No me dice cuánto avanzar el cursor después

En realidad, sospecho fuertemente que esto no ocurrirá muy a menudo ... pero realmente me gustaría hacer lo correcto aquí. También me gustaría poder hacerlo sin convertirme en un experto en Unicode o implementarlo yo mismo :)

(Planteado como error 210 en Noda Time, en caso de que alguien quiera seguir alguna conclusión final).

Me gusta la idea de la normalización. Necesito verificar eso en detalle para a) corrección yb) desempeño. Suponiendo que pueda hacer que funcione correctamente, todavía no estoy seguro de si valdría la pena cambiarlo todo: es el tipo de cosas que probablemente nunca aparecerán en la vida real, pero que podrían dañar el rendimiento de todos mis usuarios: (

También verifiqué el BCL, que tampoco parece manejar esto correctamente. Código de muestra:

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

Cambiar el nombre del mes personalizado a solo "cama" con un valor de texto de "bEd" se analiza bien.

Bien, algunos puntos de datos más:

  • El costo de usar Substringy IsPrefixes significativo pero no horrible. En una muestra de "Viernes 12 de abril de 2013 20:28:42" en mi computadora portátil de desarrollo, cambia el número de operaciones de análisis que puedo ejecutar en un segundo de aproximadamente 460 K a aproximadamente 400 K. Prefiero evitar esa desaceleración si es posible, pero no está tan mal.

  • La normalización es menos factible de lo que pensaba, porque no está disponible en las bibliotecas de clases portátiles. Potencialmente, podría usarlo solo para compilaciones que no sean PCL, lo que permite que las compilaciones PCL sean un poco menos correctas. El impacto de rendimiento de las pruebas de normalización ( string.IsNormalized) reduce el rendimiento a aproximadamente 445K llamadas por segundo, con lo que puedo vivir. Todavía no estoy seguro de que haga todo lo que necesito, por ejemplo, un nombre de mes que contenga "ß" debería coincidir con "ss" en muchas culturas, creo ... y la normalización no hace eso.

Jon Skeet
fuente
Si bien entiendo su deseo de evitar el impacto en el rendimiento de la creación de una subcadena, podría ser mejor hacerlo, pero al principio del juego cambiando todo a una forma de normalización Unicode elegida PRIMERO y luego sabiendo que puede caminar "punto por punto ". Probablemente en forma D.
Disponible el
@IDisposable: Sí, me pregunté sobre eso. Obviamente, puedo normalizar los nombres de los meses de antemano. Al menos puedo hacer la normalización solo una vez. Me pregunto si el procedimiento de normalización verifica si es necesario hacer algo primero. No tengo mucha experiencia en normalización, definitivamente es una vía para investigar.
Jon Skeet
1
Si textno es demasiado largo, puede hacerlo if (compareInfo.IndexOf(text, candidate, position, options) == position). msdn.microsoft.com/en-us/library/ms143031.aspx Pero si textes muy largo, perderá mucho tiempo buscando más allá de donde se necesita.
Jim Mischel
1
Sólo bypass mediante la Stringclase en absoluto en este caso y utilizar una Char[]forma directa. Terminará escribiendo más código, pero eso es lo que sucede cuando desea un alto rendimiento ... o tal vez debería programar en C ++ / CLI ;-)
intrepidis
1
¿ CompareOptions.IgnoreNonSpace no se encargará de esto automágicamente por usted? Me parece (desde el docco, no estoy en condiciones de probar desde este iPad, ¡lo siento!) Como si este pudiera ser un caso de uso ( ¿ el ?) Para esa opción. " Indica que la comparación de cadenas debe ignorar los caracteres de combinación sin espacios, como los
signos

Respuestas:

41

Consideraré el problema de muchos <-> uno / muchos mapeos de casos primero y por separado del manejo de diferentes formularios de normalización.

Por ejemplo:

x heiße y
  ^--- cursor

Coincide heissepero luego mueve el cursor 1 demasiado. Y:

x heisse y
  ^--- cursor

Coincide heißepero luego mueve el cursor 1 demasiado menos.

Esto se aplicará a cualquier personaje que no tenga una asignación simple uno a uno.

Necesitaría saber la longitud de la subcadena que realmente coincidió. Pero Compare, IndexOf..etc tira esa información. Podría ser posible con expresiones regulares, pero la aplicación no hacer caso de plegado completo y así no coincide ßcon ss/SSel modo de mayúsculas y minúsculas, aunque .Comparey .IndexOfhacer. Y probablemente sería costoso crear nuevas expresiones regulares para cada candidato de todos modos.

La solución más simple para esto es simplemente almacenar internamente cadenas en forma de caso plegado y hacer comparaciones binarias con candidatos de caso plegado. Entonces puede mover el cursor correctamente con solo .Lengthya que el cursor es para representación interna. También recupera la mayor parte del rendimiento perdido al no tener que usarlo CompareOptions.IgnoreCase.

Desafortunadamente, no hay una función de plegado de casos incorporada y el plegado de casos del pobre tampoco funciona porque no hay un mapeo completo de casos, el ToUppermétodo no se convierte ßen SS.

Por ejemplo, esto funciona en Java (e incluso en Javascript), dada la cadena que está en forma 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);
}

Es divertido notar que la comparación de mayúsculas y minúsculas de Java no hace el plegado completo de mayúsculas y minúsculas como C # CompareOptions.IgnoreCase. Entonces, son opuestos en este sentido: Java hace mapeo de casos completo, pero plegado de caso simple: C # hace mapeo de caso simple, pero plegado de caso completo.

Por lo tanto, es probable que necesite una biblioteca de terceros para doblar sus cuerdas antes de usarlas.


Antes de hacer cualquier cosa, debe asegurarse de que sus cadenas estén en forma normal C.Puede utilizar esta verificación rápida preliminar optimizada para el alfabeto latino:

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

Esto da falsos positivos pero no falsos negativos, no espero que disminuya la velocidad de 460k análisis / s en absoluto cuando se usan caracteres de escritura latina a pesar de que debe realizarse en cada cadena. Con un falso positivo lo usaría IsNormalizedpara obtener un verdadero negativo / positivo y solo después de eso normalizarlo si es necesario.


Entonces, en conclusión, el procesamiento es asegurar primero la forma C normal, luego el plegado de la caja. Haga comparaciones binarias con las cadenas procesadas y mueva el cursor a medida que lo mueve actualmente.

Esailija
fuente
Gracias por esto, tendré que analizar la forma de normalización C con más detalle, pero estos son excelentes consejos. Creo que puedo vivir con el "no funciona del todo correctamente bajo la PCL" (que no proporciona normalización). El uso de una biblioteca de terceros para el plegado de casos sería excesivo aquí; actualmente no tenemos dependencias de terceros, e introducir una solo para un caso de esquina que incluso el BCL no maneja sería una molestia. Es de suponer que el plegado de mayúsculas y minúsculas es sensible a la cultura, por cierto (por ejemplo, turco).
Jon Skeet
2
@JonSkeet sí, Turkic merece su propio modo en las asignaciones de casefold: P Consulte la sección de formato en el encabezado de CaseFolding.txt
Esailija
Esta respuesta parece tener un defecto fundamental, ya que implica que los caracteres se asignan a ligaduras (y viceversa) solo cuando se pliegan mayúsculas y minúsculas. Este no es el caso; hay ligaduras que se consideran iguales a los caracteres independientemente de la carcasa. Por ejemplo, en la referencia cultural en-US, æes igual a aey es igual a ffi. La normalización C no maneja ligaduras en absoluto, ya que solo permite asignaciones de compatibilidad (que generalmente están restringidas a la combinación de caracteres).
Douglas
La normalización KC y KD maneja algunas ligaduras, como , pero pasa por alto otras, como æ. El problema se agrava por las discrepancias entre culturas: æes igual a aeen-US, pero no a da-DK, como se explica en la documentación de MSDN para cadenas . Por lo tanto, la normalización (a cualquier forma) y el mapeo de casos no son una solución suficiente para este problema.
Douglas
Pequeña corrección a mi comentario anterior: la normalización C solo permite asignaciones canónicas (como para combinar caracteres), no asignaciones de compatibilidad (como para ligaduras).
Douglas
21

Vea si esto cumple con el 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.Comparesolo se realiza una vez que se sourceinició con prefix; si no lo hizo, IsPrefixregresa -1; de lo contrario, la longitud de los caracteres utilizados en source.

Sin embargo, no tengo ni idea, excepto de la subasta length2por 1el siguiente caso:

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

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

actualización :

Intenté mejorar un poco el rendimiento, pero no está probado si hay un error en el siguiente código:

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

Probé con el caso particular y la comparación bajó a aproximadamente 3.

Ken Kin
fuente
Yo realmente preferiría no tener que bucle como este. Es cierto que con la salida anticipada solo tendrá que hacer un bucle si se encuentra algo, pero aún así prefiero no tener que hacer comparaciones de 8 cadenas solo para que coincida con "febrero", por ejemplo. Parece que debe haber una mejor manera. Además, la IndexOfoperación inicial tiene que mirar a través de toda la cadena desde la posición inicial, lo que sería un problema de rendimiento si la cadena de entrada es larga.
Jon Skeet
@JonSkeet: Gracias. Tal vez se pueda agregar algo para detectar si se puede disminuir el bucle. Pensaré sobre eso.
Ken Kin
@JonSkeet: ¿Consideraría utilizar la reflexión? Desde que rastreé los métodos, no están lejos de invocar métodos nativos.
Ken Kin
3
En efecto. Noda Time no quiere meterse en el negocio de los detalles Unicode :)
Jon Skeet
2
He resuelto un problema similar una vez como este (resaltado de cadena de búsqueda en HTML). Lo hice de manera similar. Puede ajustar el bucle y la estrategia de búsqueda de manera que se complete muy rápidamente al verificar primero los casos probables. Lo bueno de esto es que parece ser totalmente correcto y no se filtran detalles Unicode en su código.
usr
9

En realidad, esto es posible sin normalización y sin usar IsPrefix.

Necesitamos comparar el mismo número de elementos de texto en contraposición al mismo número de caracteres, pero aún así devolver el número de caracteres coincidentes.

Creé una copia del MatchCaseInsensitivemétodo de ValueCursor.cs en Noda Time y lo modifiqué ligeramente para que pueda usarse en un 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;
    }
}

(Solo se incluye como referencia, es el código que no se comparará correctamente como sabe)

La siguiente variante de ese método usa StringInfo.GetNextTextElement que es proporcionado por el marco. La idea es comparar elemento de texto por elemento de texto para encontrar una coincidencia y, si se encuentra, devolver el número real de caracteres coincidentes en la cadena de origen:

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

Ese método funciona bien al menos de acuerdo con mis casos de prueba (que básicamente solo prueban un par de variantes de las cadenas que ha proporcionado: "b\u00e9d"y "be\u0301d").

Sin embargo, el método GetNextTextElement crea una subcadena para cada elemento de texto, por lo que esta implementación requiere muchas comparaciones de subcadenas, lo que tendrá un impacto en el rendimiento.

Entonces, creé otra variante que no usa GetNextTextElement, sino que omite los caracteres de combinación Unicode para encontrar la longitud real de coincidencia en 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;
}

Ese método utiliza los siguientes dos ayudantes:

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

No he realizado ninguna evaluación comparativa, por lo que realmente no sé si el método más rápido es realmente más rápido. Tampoco he realizado pruebas prolongadas.

Pero esto debería responder a su pregunta sobre cómo realizar una coincidencia de subcadenas sensibles a la cultura para cadenas que pueden incluir caracteres de combinación Unicode.

Estos son los casos de prueba que he usado:

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

Los valores de la tupla son:

  1. La cadena de origen (pajar)
  2. La posición inicial en la fuente.
  3. La cuerda de fósforo (aguja).
  4. La duración esperada del partido.

Ejecutar esas pruebas en los tres métodos produce 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

Las dos últimas pruebas están probando el caso cuando la cadena de origen es más corta que la cadena de coincidencia. En este caso, el método original (tiempo de Noda) también tendrá éxito.

Mårten Wikström
fuente
Muchas gracias por esto. Necesitaré revisarlo en detalle para ver qué tan bien funciona, pero parece un gran punto de partida. Más conocimiento de Unicode (en el código mismo) de lo que esperaba que se requiriera, pero si la plataforma no hace lo que se requiere, no hay mucho que pueda hacer al respecto :(
Jon Skeet
@JonSkeet: ¡Me alegra ser de alguna ayuda! Y sí, la coincidencia de subcadenas con soporte Unicode definitivamente debería haberse incluido en el marco ...
Mårten Wikström