Как я могу выполнить операцию «начинается с» с учетом языка и региональных параметров с середины строки?

106

У меня есть требование , которое является относительно неясным, но он чувствует , как это должно быть возможно с помощью BCL.

Для контекста я анализирую строку даты / времени в Noda Time . Я поддерживаю логический курсор для моей позиции во входной строке. Таким образом, хотя полная строка может быть «3 января 2013 года», логический курсор может быть на «J».

Теперь мне нужно проанализировать название месяца, сравнив его со всеми известными названиями месяцев для культуры:

  • С учетом культуры
  • Без учета регистра
  • Просто от точки курсора (не позже; я хочу видеть, "смотрит ли курсор" на название месяца кандидата)
  • Быстро
  • ... а потом мне нужно знать, сколько символов было использовано

Текущий код , чтобы сделать это , как правило работает, используя CompareInfo.Compare. Фактически это примерно так (только для соответствующей части - в реальной вещи больше кода, но он не имеет отношения к совпадению):

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

Однако это зависит от того, что кандидат и сравниваемая область имеют одинаковую длину. Чистовая большую часть времени, но не хорошо в некоторых особых случаях. Допустим, у нас есть что-то вроде:

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

Теперь мое сравнение не удастся. Я мог бы использовать IsPrefix:

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

но:

  • Для этого мне нужно создать подстроку, чего я бы предпочел избежать. (Я рассматриваю Noda Time как эффективную системную библиотеку; производительность анализа может быть важна для некоторых клиентов.)
  • Он не говорит мне, как далеко продвинуть курсор после этого

На самом деле, я сильно подозреваю, что это будет происходить не очень часто ... но мне бы очень хотелось поступить правильно. Я также очень хотел бы иметь возможность делать это, не становясь экспертом по Unicode и не внедряя его самостоятельно :)

(Поднят как ошибка 210 в Noda Time, на случай, если кто-то захочет следовать какому-либо окончательному выводу.)

Мне нравится идея нормализации. Мне нужно подробно проверить это на предмет а) правильности и б) производительности. Предполагая, что я смогу заставить его работать правильно, я все еще не уверен, стоит ли вообще его менять - это такие вещи, которые, вероятно, никогда не появятся в реальной жизни, но могут повредить производительности всех моих пользователей: (

Я также проверил BCL, который, похоже, тоже не справляется с этим должным образом. Образец кода:

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

Если изменить название месяца на просто "bed" с текстовым значением "bEd", синтаксический анализ будет выполнен нормально.

Хорошо, еще несколько точек данных:

  • Стоимость использования Substringи IsPrefixзначительна, но не ужасна. В примере «Friday April 12 2013 20:28:42» на моем портативном компьютере для разработки он изменяет количество операций синтаксического анализа, которые я могу выполнить за секунду, с примерно 460K до примерно 400K. Я бы предпочел по возможности избежать этого замедления, но это не так уж плохо.

  • Нормализация менее осуществима, чем я думал, потому что она недоступна в переносимых библиотеках классов. Я потенциально мог бы использовать его только для сборок без PCL, что позволяет сборкам PCL быть немного менее правильными. Тестирование на normalization ( string.IsNormalized) снижает производительность примерно до 445 тыс. Вызовов в секунду, с чем я могу жить. Я до сих пор не уверен, что он делает все, что мне нужно - например, название месяца, содержащее "ß", должно соответствовать "ss" во многих культурах, я считаю ... и нормализация этого не делает.

Джон Скит
источник
Хотя я понимаю ваше желание избежать снижения производительности при создании подстроки, возможно, лучше сделать это, но на ранних этапах игры, переведя все в выбранную форму нормализации Юникода СНАЧАЛА, а затем зная, что вы можете ходить "по пунктам" ". Вероятно, D-форма.
IDisposable
@IDisposable: Да, мне это было интересно. Очевидно, я могу заранее нормализовать сами названия месяцев. По крайней мере, я могу сделать нормализацию только один раз. Интересно, проверяет ли процедура нормализации, нужно ли что-нибудь сначала сделать. У меня нет большого опыта в нормализации - определенно один путь, на который стоит обратить внимание.
Джон Скит,
1
Если ваш textне слишком длинный, вы можете сделать это if (compareInfo.IndexOf(text, candidate, position, options) == position). msdn.microsoft.com/en-us/library/ms143031.aspx Но если textэто очень долго, это потратит много времени на поиск за пределами того места, где это необходимо.
Джим Мишель,
1
Просто шунтирование с использованием Stringкласса вообще в данном случае и использовать Char[]непосредственно. Вы в конечном итоге напишете больше кода, но это то, что происходит, когда вам нужна высокая производительность ... или, может быть, вам следует программировать на C ++ / CLI ;-)
intrepidis
1
Не позаботится ли CompareOptions.IgnoreNonSpace об этом автоматически? Она смотрит на меня (от docco, не в состоянии проверить с этого IPad жаль!) , Как будто это могло бы быть ( ?) Вариант использования для этой опции. « Указывает, что при сравнении строк должны игнорироваться непроходящие комбинирующие символы, такие как диакритические знаки »
Сепстер,

Ответы:

41

Сначала я рассмотрю проблему многих <-> one / many casemappings отдельно от обработки различных форм нормализации.

Например:

x heiße y
  ^--- cursor

Соответствует, heisseно затем слишком сильно перемещает курсор 1. И:

x heisse y
  ^--- cursor

Соответствует, heißeно затем перемещает курсор 1 меньше.

Это будет применяться к любому персонажу, у которого нет простого взаимно-однозначного сопоставления.

Вам нужно будет знать длину фактически совпавшей подстроки. Но… Compareи IndexOfт.д. выбросьте эту информацию. Это может быть возможным с регулярными выражениями , но реализация не делает полный случай складывания и поэтому не соответствует , ßчтобы ss/SSв режиме без учета регистра , даже если .Compareи .IndexOfделать. И, вероятно, в любом случае создание новых регулярных выражений для каждого кандидата будет дорогостоящим.

Самое простое решение - просто хранить строки в свернутой форме и выполнять двоичные сравнения с кандидатами в свернутом регистре. Затем вы можете правильно перемещать курсор, .Lengthпоскольку он предназначен для внутреннего представления. Вы также получаете большую часть потерянной производительности из-за того, что вам не нужно его использовать CompareOptions.IgnoreCase.

К сожалению, нет встроенной функции сворачивания кейсов, и сворачивание кейсов бедняги тоже не работает, потому что нет полного отображения кейсов - ToUpperметод не превращается ßв SS.

Например, это работает в Java (и даже в Javascript), учитывая строку, которая находится в нормальной форме 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);
}

Интересно отметить, что сравнение игнорирования регистра в Java не выполняет полного сворачивания регистра, как в C # CompareOptions.IgnoreCase. Таким образом, они противоположны в этом отношении: Java выполняет полное отображение регистра, но простое сворачивание регистра - C # делает простое отображение регистра, но полное сворачивание регистра.

Поэтому вполне вероятно, что вам понадобится сторонняя библиотека, чтобы свернуть строки перед их использованием.


Прежде чем что-либо делать, вы должны убедиться, что ваши строки имеют нормальную форму C. Вы можете использовать эту предварительную быструю проверку, оптимизированную для латинского алфавита:

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

Это дает ложные срабатывания, но не ложноотрицательные, я не ожидаю, что это вообще замедлит 460 тыс. Синтаксических анализов / с при использовании символов латинского алфавита, даже если это необходимо выполнять для каждой строки. При ложном срабатывании вы могли бы IsNormalizedполучить истинно отрицательный / положительный результат и только после этого при необходимости нормализовать.


Итак, в заключение, обработка должна сначала обеспечить нормальную форму C, а затем регистр. Выполняйте двоичные сравнения с обработанными строками и перемещайте курсор по мере его перемещения.

Esailija
источник
Спасибо за это - мне нужно более подробно изучить форму нормализации C, но это отличные указатели. Я думаю, что могу жить с тем, что «это не совсем корректно работает под PCL» (что не обеспечивает нормализации). Использование сторонней библиотеки для сворачивания корпуса было бы здесь излишним - в настоящее время у нас нет сторонних зависимостей, и введение одной только для углового случая, с которым даже BCL не справляется, было бы проблемой. Предположительно складывание регистра чувствительно к культуре, кстати (например, турецкое)?
Джон Скит
2
@JonSkeet да, тюркский заслуживает своего собственного режима в отображении casefold: P См. Раздел формата в заголовке CaseFolding.txt
Esailija
У этого ответа, по-видимому, есть фундаментальный недостаток, поскольку он подразумевает, что символы сопоставляются лигатурам (и наоборот) только при сворачивании регистра. Это не вариант; есть лигатуры, которые считаются равными символам независимо от регистра. Например, для языка и региональных параметров en-US æравно aeи равно ffi. C-нормализация вообще не обрабатывает лигатуры, поскольку позволяет только сопоставления совместимости (которые обычно ограничиваются объединением символов).
Дуглас
KC- и KD-нормализация обрабатывает некоторые лигатуры, например , но пропускает другие, например æ. Проблема усугубляется несоответствием между культурами - æравно aeen-US, но не da-DK, как описано в документации MSDN для строк . Таким образом, нормализация (к любой форме) и отображение случаев не являются достаточным решением этой проблемы.
Дуглас
Небольшая поправка к моему предыдущему комментарию: C-нормализация допускает только канонические сопоставления (например, для объединения символов), но не сопоставления совместимости (например, для лигатур).
Дуглас
21

Посмотрите, соответствует ли это требованиям ..:

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.Compareвыполняет только один раз, sourceначатый с prefix; если нет, то IsPrefixвозвращается -1; в противном случае длина символов, используемых в source.

Тем не менее, я понятия не имею , кроме прироста length2по 1со следующим случаем:

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

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

обновление :

Я попытался немного улучшить производительность, но не доказано, есть ли ошибка в следующем коде:

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

Я тестировал конкретный случай, и сравнение снизилось примерно до 3.

Кен Кин
источник
Я бы действительно предпочел не повторять такой цикл. По общему признанию, при раннем выпуске ему нужно будет выполнить цикл только в том случае, если он что-то обнаружит, но я бы все равно не делал 8 сравнений строк, например, для соответствия «февралю». Такое чувство, что должен быть способ получше. Кроме того, начальная IndexOfоперация должна просматривать всю строку с начальной позиции, что может снизить производительность, если входная строка длинная.
Джон Скит
@JonSkeet: Спасибо. Может быть, что-то можно добавить, чтобы определить, можно ли уменьшить цикл. Я подумаю об этом.
Кен Кин
@JonSkeet: Не могли бы вы использовать отражение? Поскольку я проследил за методами, они недалеки от вызова собственных методов.
Кен Кин
3
На самом деле. Noda Time не хочет вдаваться в подробности Юникода :)
Джон Скит
2
Однажды я решил аналогичную проблему (выделение строки поиска в HTML). Я сделал то же самое. Вы можете настроить цикл и стратегию поиска таким образом, чтобы они выполнялись очень быстро, предварительно проверив вероятные случаи. Приятно то, что это кажется полностью правильным и никакие детали Unicode не попадают в ваш код.
usr
9

На самом деле это возможно без нормализации и без использования IsPrefix.

Нам нужно сравнить такое же количество текстовых элементов, а не такое же количество символов, но все же вернуть количество совпадающих символов.

Я создал копию MatchCaseInsensitiveметода из ValueCursor.cs в Noda Time и немного изменил его, чтобы его можно было использовать в статическом контексте:

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

(Только для справки, это код, который, как вы знаете, некорректно сравнивать)

Следующий вариант этого метода использует StringInfo.GetNextTextElement, предоставляемый платформой. Идея состоит в том, чтобы сравнить текстовый элемент с текстовым элементом, чтобы найти совпадение и, если оно найдено, вернуть фактическое количество совпадающих символов в исходной строке:

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

Этот метод отлично работает, по крайней мере, в соответствии с моими тестовыми примерами (которые в основном просто проверяют пару вариантов предоставленных вами строк: "b\u00e9d"и "be\u0301d").

Однако метод GetNextTextElement создает подстроку для каждого текстового элемента, поэтому эта реализация требует большого количества сравнений подстрок, что повлияет на производительность.

Итак, я создал другой вариант, который не использует GetNextTextElement, а вместо этого пропускает объединяющие символы Unicode, чтобы найти фактическую длину совпадения в символах:

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

В этом методе используются два следующих помощника:

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

Я не проводил тестирования производительности, поэтому не знаю, действительно ли более быстрый метод быстрее. Я также не проводил расширенного тестирования.

Но это должно ответить на ваш вопрос о том, как выполнить сопоставление подстрок с учетом культурных особенностей для строк, которые могут включать символы объединения Unicode.

Я использовал следующие тестовые примеры:

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

Значения кортежа:

  1. Исходная строка (стог сена)
  2. Начальная позиция в источнике.
  3. Соответствующая строка (игла).
  4. Ожидаемая продолжительность матча.

Выполнение этих тестов для трех методов дает следующий результат:

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

Последние два теста проверяют случай, когда исходная строка короче строки соответствия. В этом случае будет успешно использован и оригинальный метод (время Noda).

Мартен Викстрём
источник
Спасибо тебе большое за это. Мне нужно изучить его подробно, чтобы увидеть, насколько хорошо он работает, но он выглядит как отличная отправная точка. Больше знаний о Unicode (в самом коде), чем я ожидал , потребуются, но если платформа не выполняет то, что требуется, я мало что могу с этим поделать :(
Джон Скит
@JonSkeet: Рад помочь! И да, сопоставление подстрок с поддержкой Unicode обязательно должно было быть включено в структуру ...
Мартен Викстрём,