С# SortedList <string, TValue>.ContainsKey для успешно добавленного ключа возвращает false

ПРОВЕРИТЬ ОБНОВЛЕНИЕ 3 ниже Я узнал, что проблема, с которой я столкнулся, связана с известной серьезной проблемой с С# -строчными сопоставлениями для .Net 4.0, 4.0 client и 4.5, что приведет к несогласованному порядку сортировки списков строк (что приведет к тому, что вывод будет зависеть от порядка в используемый вход и алгоритм сортировки). Проблема была сообщена Microsoft в декабре 2012 года и закрыта как "не будет исправлена". Доступна работа, но она намного медленнее, что вряд ли практично для больших коллекций.

При внедрении неизменяемого PatriciaTrie я хотел сравнить его производительность с System.Collections.Generic.SortedList. Я использовал следующий файл https://github.com/rkapsi/patricia-trie/blob/master/src/test/resources/org/ardverk/collection/hamlet.txt, чтобы создать список слов для тестирования.

При вставке каждого слова из С# SortedList, используя Comparer<string>.Default или StringComparer.InvariantCulture в качестве сопоставителя клавиш, количество вставленных записей не может быть восстановлено с использованием обычных методов поиска (например, ContainsKey возвращает false), но ключ присутствует в списке, как это наблюдается при повторении списка.

Еще более любопытно, что компаратор возвращает значение "0" при сравнении ключа, полученного из отсортированного списка, с помощью ключа поиска, который невозможно найти с помощью ContainsKey.

Полный пример ниже демонстрирует эту проблему в моей системе.

using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;

class Program
{
    static void Main(string[] args)
    {
        // the problem is possibly related to comparison.
        var fail = true;
        var comparer = fail ? StringComparer.InvariantCulture : StringComparer.Ordinal;

        // read hamlet (contains duplicate words)
        var words = File
            .ReadAllLines("hamlet.txt")
            .SelectMany(l => l.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries))
            .Select(w => w.Trim())
            .Where(w => !string.IsNullOrEmpty(w))
            .Distinct(comparer)
            .ToArray();

        // insert hamlet words in the sorted list.
        var list = new SortedList<string, int>(comparer);
        var ndx = 0;
        foreach (var word in words)
            list[word] = ndx++;

        // search for each of the added words.
        foreach (var keyToSearch in words)
        {
            if (!list.ContainsKey(keyToSearch))
            {
                // was inserted, but cannot be retrieved.
                Console.WriteLine("Error - Key not found: \"{0}\"", keyToSearch);
                // however when we iterate over the list, we see that the entry is present
                var prefix = keyToSearch.Substring(0, Math.Min(keyToSearch.Length, 3));
                foreach (var wordCloseToSearchKey in list.Keys.Where(s => s.StartsWith(prefix)))
                {
                    // and using the SortedList supplied comparison returns 0, signaling equality
                    var comparisonResult = list.Comparer.Compare(wordCloseToSearchKey, keyToSearch);
                    Console.WriteLine("{0} - comparison result = {1}", wordCloseToSearchKey, comparisonResult);
                }
            }
        }

        // Check that sort order of List.Keys is correct 
        var keys = list.Keys.ToArray();
        BinarySearchAll("list.Keys", keys, list.Comparer);
        CheckCorrectSortOrder("list.Keys", keys, list.Comparer);

        // Check that sort order of Array.Sort(List.Keys) is correct 
        var arraySortedKeys = CopySortSearchAndCheck("Array.Sort(List.Keys)", keys, list.Comparer);

        // Check that sort order of the Array.Sort(input words) is correct 
        var sortedInput = CopySortSearchAndCheck("Array.Sort(input words)", words, list.Comparer);

        Console.ReadLine();
    }

    static string[] CopySortSearchAndCheck(string arrayDesc, string[] input, IComparer<string> comparer)
    {
        // copy input
        var sortedInput = new string[input.Length];
        Array.Copy(input, sortedInput, sortedInput.Length);

        // sort it
        Array.Sort(sortedInput, comparer);

        // check that we can actually find the keys in the array using bin. search
        BinarySearchAll(arrayDesc, sortedInput, comparer);

        // check that sort order is correct
        CheckCorrectSortOrder(arrayDesc, sortedInput, comparer);

        return sortedInput;
    }

    static void BinarySearchAll(string arrayDesc, string[] sortedInput, IComparer<string> comparer)
    {
        // check that each key in the input can be found using bin. search
        foreach (var word in sortedInput)
        {
            var ix = Array.BinarySearch(sortedInput, word, comparer);
            if (ix < 0)
                // and it appears it cannot!
                Console.WriteLine("Error - {0} - Key not found: \"{1}\"", arrayDesc, word);
        }
    }

    static void CheckCorrectSortOrder(string arrayDesc, string[] sortedKeys, IComparer<string> comparer)
    {
        for (int n = 0; n < sortedKeys.Length; n++)
        {
            for (int up = n + 1; up < sortedKeys.Length; up++)
            {
                var cmp = comparer.Compare(sortedKeys[n], sortedKeys[up]);
                if (cmp >= 0)
                {
                    Console.WriteLine(
                        "{0}[{1}] = \"{2}\" not < than {0}[{3}] = \"{4}\"  - cmp = {5}",
                        arrayDesc, n, sortedKeys[n], up, sortedKeys[up], cmp);
                }
            }
            for (int down = n - 1; down > 0; down--)
            {
                var cmp = comparer.Compare(sortedKeys[n], sortedKeys[down]);
                if (cmp <= 0)
                {
                    Console.WriteLine(
                        "{0}[{1}] = \"{2}\" not > than {0}[{3}] = \"{4}\"  - cmp = {5}",
                        arrayDesc, n, sortedKeys[n], down, sortedKeys[down], cmp);
                }
            }
        }
    }
}

Есть ли у кого-нибудь объяснение этого неожиданного и нечетного поведения?

При изменении сравнения, используемого SortedList, на StringComparer.Ordinal (например, изменяя fail на false в приведенном выше примере) проблема исчезает, что, похоже, указывает на проблему сравнения, но я не совсем понять почему.

ОБНОВЛЕНО Как отметил Себастьян, описанная здесь проблема не обнаруживается в профилях клиентов .Net 3.5 и 3.5. Он работает на .Net 4.0, 4.0 и 4.5.

После некоторого дополнительного копания я заметил, что если я возьму отсортированные ключи из списка, и я запустил Array.BinarySearch на этих клавишах, он также вернет отрицательные (не найденные) значения для тех же ключей, которые не найдены с помощью SortedList.ContainsKey. Таким образом, это предполагает, что порядок сортировки ключей неверен.

Если я возьму уже отсортированные ключи из списка и отсортирую их с помощью Array.Sort, порядок сортировки вывода отличается для ключей, которые были проблематичными.

Итак, я добавил функцию проверки (с помощью сопоставления списка), если порядок сортировки заданного массива правильный (т.е. предыдущий ключ всегда меньше, последующий ключ всегда больше) и ограничил ввод словами, которые отличный от сравнения. Я применил эту функцию на 3 разных входах (все используют один и тот же компаратор):

  • Коллекция отсортированных ключей.
  • вывод массива Array.Sort на эти клавиши.
  • вывод массива Array.Sort на вход из файла.

Результат (2) и (3) идентичен и отличается от (1). Однако выполнение Array.BinarySearch на выходах Array.Sort(2) и (3) снова не может найти одни и те же ключи (возврат < 0). Также функция, которая проверяет правильность порядка сортировки, указывает, что для всех трех случаев порядок сортировки вокруг задействованных проблемных клавиш неверен.

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

Код примера обновляется с помощью моих дополнительных экспериментов по устранению неполадок, снимок экрана выводится здесь http://imgur.com/DU8SCsA.

ОБНОВЛЕНИЕ 2 Хорошо, я сузил проблему до того, что мне кажется очень серьезной проблемой с С# строковыми сопоставителями, представленными с .Net 4.0.

Итак, предположим, что у нас есть 3 значения: a1, a2 и a3. Для любой корректной сортировки мы ожидаем, что если a1 < a2 и a2 < a3, чтобы сравнение было последовательным, как следствие, также имеет место следующее: a1 < a3.

Однако это не относится к строковым сопоставлениям С# (по крайней мере для Comparer<string>.Default и StringComparer.InvariantCulture).

Маленькая программа, приведенная ниже, иллюстрирует эту конкретную проблему:

class Program
{
    static void Main(string[] args)
    {
        var comparer = StringComparer.InvariantCulture;
        var a1 = "A";
        var a2 = "a\'";
        var a3 = "\'a";
        PrintComparison("a1", a1, "a2", a2, comparer);
        PrintComparison("a2", a2, "a3", a3, comparer);
        PrintComparison("a1", a1, "a3", a3, comparer);
        Console.ReadLine();
    }
    public static void PrintComparison(string firstSymbol, string first, string secondSymbol, string second, IComparer<string> comparer)
    {
        var cmp = comparer.Compare(first, second);
        var result = cmp == 0 ? "=" : cmp < 0 ? "<" : ">";
        Console.WriteLine("{0} {1} {2}   ({3} {1} {4})", firstSymbol, result, secondSymbol, first, second);
    }
}

Это его вывод:

a1 < a2   (A < a')
a2 < a3   (a' < 'a)
a1 > a3   (A > 'a)

Вывод, по-видимому, заключается в том, что небезопасно полагаться на порядок сортировки, определенный с помощью компиляторов строки С#, или я что-то не хватает?

ОБНОВЛЕНИЕ 3Эта проблема, по-видимому, была сообщена MS в декабре 2012 года и закрыта статусом "не будет исправлена", что довольно неутешительно; см. ссылку, размещенную в комментариях ниже (кажется, я не могу публиковать здесь из-за моих ограниченных точек репутации). В этом также приведено обходное решение, которое я выполнил и использовал для проверки того, что это действительно устраняет проблемы, наблюдаемые со стандартными компараторами.

public class WorkAroundStringComparer : StringComparer
{
    private static readonly Func<CompareInfo, string, CompareOptions, int> _getHashCodeOfString;
    private readonly CompareInfo _compareInfo;
    private readonly CompareOptions _compareOptions;

    static WorkAroundStringComparer()
    {
        // Need this internal method to compute hashcode
        // as an IEqualityComparer implementation.
        _getHashCodeOfString = BuildGetHashCodeOfStringDelegate();
    }

    static Func<CompareInfo, string, CompareOptions, int> BuildGetHashCodeOfStringDelegate()
    {
        var compareInfoType = typeof(CompareInfo);
        var argTypes = new[] { typeof(string), typeof(CompareOptions) };
        var flags = BindingFlags.NonPublic | BindingFlags.Instance;
        var methods = compareInfoType.GetMethods(flags).ToArray(); ;
        var method = compareInfoType.GetMethod("GetHashCodeOfString", flags, null, argTypes, null);

        var instance = Expression.Parameter(compareInfoType, "instance");
        var stringArg = Expression.Parameter(typeof(string), "string");
        var optionsArg = Expression.Parameter(typeof(CompareOptions), "options");
        var methodCall = Expression.Call(instance, method, stringArg, optionsArg);
        var expr = Expression.Lambda<Func<CompareInfo, string, CompareOptions, int>>(methodCall, instance, stringArg, optionsArg);
        return expr.Compile();
    }

    public WorkAroundStringComparer()
        : this(CultureInfo.InvariantCulture)
    {
    }

    public WorkAroundStringComparer(CultureInfo cultureInfo, CompareOptions compareOptions = CompareOptions.None)
    {
        if (cultureInfo == null)
            throw new ArgumentNullException("cultureInfo");
        this._compareInfo = cultureInfo.CompareInfo;
        this._compareOptions = compareOptions;
    }

    public override int Compare(string x, string y)
    {
        if (ReferenceEquals(x, y))
            return 0;
        if (ReferenceEquals(x, null))
            return -1;
        if (ReferenceEquals(y, null))
            return 1;

        var sortKeyFor_x = _compareInfo.GetSortKey(x, _compareOptions);
        var sortKeyFor_y = _compareInfo.GetSortKey(y, _compareOptions);
        return SortKey.Compare(sortKeyFor_x, sortKeyFor_y);
    }

    public override bool Equals(string x, string y)
    {
        return Compare(x, y) == 0;
    }

    public override int GetHashCode(string obj)
    {
        return _getHashCodeOfString(_compareInfo, obj, _compareOptions);
    }
}

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

Время, затрачиваемое при сортировке списка слов 1000 раз с использованием обоих сопоставлений:

StringComparer.InvariantCulture : 00:00:15.3120013
WorkAroundStringComparer        : 00:01:35.8322409

Таким образом, я все еще надеюсь, что Microsoft пересмотрит или кто-то знает жизнеспособную альтернативу. В противном случае единственная опция, которая остается, возвращается с использованием StringComparer.Ordinal.

Ответ 1

Может ли это быть связано с .Net Framework 4/4.5? Я адаптировал ваш пример для .Net 3.5 следующим образом:

var words = ReadFile("hamlet.txt");

//...

private static string[] ReadFile(string path)
{
    List<string> lines = new List<string>();
    using (StreamReader sr = new StreamReader(path))
    {
        string text = sr.ReadToEnd();
        lines.Add(text);
    }

    return lines.SelectMany(l => l.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries).Select(w => w.Trim()))
        .Where(w => !(w.ToCharArray().All(c => c == ' ')))
        .ToArray();
}

И оба сравнения отлично работают на XP с использованием .Net 3.5.