Использование Lookup в C#

Всем привет.

Сегодня я предлагаю разобраться когда может быть удобным использовать такую коллекцию в C# как Lookup.

Lookup в C# представляет из себя коллекцию ключей, каждый из которых сопоставлен с одним или несколькими значениями.

Если очень кратко попробовать описать Lookup для тех кто с ним никогда не работал, то я бы сделал это так: неизменяемый Dictionary, способный хранить несколько значений под одним ключом. (это, конечно, не Dictionary, используется аналогия)

Lookup имеет несколько особенностей, на которые необходимо обратить внимание.

  1. Lookup является неизменяемой коллекцией. Мы не можем добавлять или удалять какие-либо элементы.
  2. При получении значения из коллекции, если значение отсутствует, мы получаем пустую коллекцию, а не null.
  3. При необходимости получения возможности изменения коллекции, наиболее близким аналогом будет являться Dictionary<TKey, Collection>, но, конечно, со своими особенностями работы.

Для примера разберём такую ситуацию: вы пишете игру, пользователи которой могут иметь имя и возраст, возраст игрового персонажа может быть от 1 до 10000. Вам, для каких-то целей, необходимо получать количество пользователей и определённым возрастом, и список имён пользователей с заданным возрастом.

Класс для работы с пользователями сделаем максимально простым:

internal class User
{
    public string Name { get; set; }
    [Range(1, 10000)]
    public int Age { get; set; }
}

Все пользователи будут попадать в список, вида List, никаких баз данных использовать не будем, так как это может изменить сценарий взаимодействия.

Для использования Lookup мне необходимо сделать следующее

var usersLoopup = _users.ToLookup(u => u.Age, u => u.Name);

А дальше я могу просто обращаться к usersLoopup как, например, к Dictionary - usersLoopup[31], и получить список имён пользователей, либо пустой список, если под определённым ключом нет значений.

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

  1. Использование Lookup
  2. Использование GroupBy и поиск по сгруппированным данным
  3. Использование GroupBy, затем формирование Dictionary и получения результатов используя Dictionary
  4. Формирование Dictionary и использование только его для работы с данными.

Для проверки скорости работы используя BenchmarkDotNet, я написал следующий класс:

public class LookupBenchmarks
{
    private const int N = 100000;
    private readonly List _users;
    private readonly int[] _ages = new int[] { 1, 5, 7, 13, 18, 21, 25, 28, 30, 33, 35, 40, 45, 50, 60, 70, 73, 80, 85, 90, 95, 100, 256, 512, 1024, 2056, 9999 };

    public LookupBenchmarks()
    {
        var fixture = new Fixture();
        _users = new List(N);
        for (int i = 0; i < N; i++)
        {
            _users.Add(fixture.Create());
        }
    }

    [Benchmark]
    public void UseLookup()
    {
        var usersLoopup = _users.ToLookup(u => u.Age, u => u.Name);
        foreach (var age in _ages)
        {
            var names = usersLoopup[age];
            var count = names.Count();
        }
    }

    [Benchmark]
    public void UseGroupBy()
    {
        var usersGroupBy = _users.GroupBy(u => u.Age);
        foreach (var age in _ages)
        {
            var names = usersGroupBy.FirstOrDefault(u => u.Key == age);
            var count = names?.Count() ?? 0;
        }
    }

    [Benchmark]
    public void UseDictionary()
    {
        var usersDictionary = _users.GroupBy(u => u.Age).ToDictionary(u => u.Key, u => u.Select(z => z.Name).ToList());
        foreach (var age in _ages)
        {
            usersDictionary.TryGetValue(age, out List names);
            var count = names?.Count() ?? 0;
        }
    }

    [Benchmark]
    public void UseManualDictionary()
    {
        var usersDictionary = new Dictionary<int, List>();
        foreach (var user in _users)
        {
            usersDictionary.TryGetValue(user.Age, out List users);
            if (users == null)
            {
                users = new List();
                usersDictionary.Add(user.Age, users);
            }
            users.Add(user.Name);
        }
        foreach (var age in _ages)
        {
            usersDictionary.TryGetValue(age, out List names);
            _ = names?.Count ?? 0;
        }
    }
}

Я получил следующий результат.

Очевидно, что использование GroupBy наименее эффективно без дополнительной обработки данных. Использование ToDictionary, после GroupBy заметно повышает эффективность, что, конечно, ожидаемо, так как использование последовательного поиска для подобных данных это очень медленно.

А вот заполнение Dictionary вручную, показывает отличный результат, но в то же время заставляет писать дополнительный код.

На мой взгляд, разница в производительности незначительная, и гораздо важнее то, что при использовании Lookup для похожих сценарием код будет оставаться более читаемым и простым. Менее подверженным ошибкам и легко изменяемым.

Вывод очевиден – выбираемая коллекция должна наиболее точно соответствовать поставленной задаче. Необходимо учитывать не только объём возможных данных, максимальное время работы выбранного алгоритма, но и то, как написанный код будет поддерживаться в будущем. Но если вам необходимо бороться за производительность максимально, в ущерб читабельности и простоты, тогда, конечно, необходимо избегать LINQ в коде.

Приятного программирования.

P.S. Большое спасибо всем кто заметил что это первая статься за очень длительное время. В ближайшем будущем я собираюсь писать чаще и, возможно, отписать о причинах происходящего.

Добавить комментарий