«Нулевые» «карты»: является ли решение обратного вызова медленнее, чем tryGet () - PullRequest
2 голосов
/ 21 апреля 2010

В комментариях к «Как реализовать List, Set и Map в нулевом бесплатном дизайне?» , Steven Sudit и я вступил в дискуссию об использовании обратного вызова с обработчиками для ситуаций "найдено" и "не найдено" вместо метода tryGet(), принимающего параметр out и возвращающего логическое значение, указывающее, был ли заполнен параметр out. Стивен утверждал, что метод обратного вызова был более сложным и почти наверняка медленным; Я утверждал, что сложность была не больше, а производительность в худшем случае такая же.

Но код говорит громче, чем слова, поэтому я решил реализовать оба варианта и посмотреть, что у меня получилось. Первоначальный вопрос был довольно теоретическим в отношении языка («И ради аргумента, допустим, у этого языка даже нет null») - я использовал Java здесь, потому что это то, что мне пригодилось. У Java нет параметров out, но у нее также нет функций первого класса, поэтому по стилю она должна одинаково сосать для обоих подходов.

(Отступление: Что касается сложности: мне нравится дизайн обратного вызова, потому что он по своей сути заставляет пользователя API обрабатывать оба случая, тогда как дизайн tryGet() требует, чтобы вызывающие абоненты выполняли свою собственную условную проверку, которая могла забыть или ошибиться. Но теперь, когда реализовали оба варианта, я понимаю, почему tryGet() дизайн выглядит проще, по крайней мере, в краткосрочной перспективе.)

Во-первых, пример обратного вызова:

class CallbackMap<K, V> {
    private final Map<K, V> backingMap;

    public CallbackMap(Map<K, V> backingMap) {
        this.backingMap = backingMap;
    }

    void lookup(K key, Callback<K, V> handler) {
        V val = backingMap.get(key);
        if (val == null) {
            handler.handleMissing(key);
        } else {
            handler.handleFound(key, val);
        }
    }
}

interface Callback<K, V> {
    void handleFound(K key, V value);
    void handleMissing(K key);
}

class CallbackExample {
    private final Map<String, String> map;
    private final List<String> found;
    private final List<String> missing;
    private Callback<String, String> handler;

    public CallbackExample(Map<String, String> map) {
        this.map = map;
        found = new ArrayList<String>(map.size());
        missing = new ArrayList<String>(map.size());
        handler = new Callback<String, String>() {
            public void handleFound(String key, String value) {
                found.add(key + ": " + value);
            }

            public void handleMissing(String key) {
                missing.add(key);
            }
        };
    }

    void test() {
        CallbackMap<String, String> cbMap = new CallbackMap<String, String>(map);
        for (int i = 0, count = map.size(); i < count; i++) {
            String key = "key" + i;
            cbMap.lookup(key, handler);
        }
        System.out.println(found.size() + " found");
        System.out.println(missing.size() + " missing");
    }
}

Теперь, пример tryGet() - насколько я понимаю схему (и я вполне могу ошибаться):

class TryGetMap<K, V> {
    private final Map<K, V> backingMap;

    public TryGetMap(Map<K, V> backingMap) {
        this.backingMap = backingMap;
    }

    boolean tryGet(K key, OutParameter<V> valueParam) {
        V val = backingMap.get(key);
        if (val == null) {
            return false;
        }
        valueParam.value = val;
        return true;
    }
}

class OutParameter<V> {
    V value;
}

class TryGetExample {
    private final Map<String, String> map;
    private final List<String> found;
    private final List<String> missing;

    private final OutParameter<String> out = new OutParameter<String>();

    public TryGetExample(Map<String, String> map) {
        this.map = map;
        found = new ArrayList<String>(map.size());
        missing = new ArrayList<String>(map.size());
    }

    void test() {
        TryGetMap<String, String> tgMap = new TryGetMap<String, String>(map);

        for (int i = 0, count = map.size(); i < count; i++) {
            String key = "key" + i;
            if (tgMap.tryGet(key, out)) {
                found.add(key + ": " + out.value);
            } else {
                missing.add(key);
            }
        }
        System.out.println(found.size() + " found");
        System.out.println(missing.size() + " missing");
    }
}

И, наконец, код теста производительности:

public static void main(String[] args) {
    int size = 200000;
    Map<String, String> map = new HashMap<String, String>();
    for (int i = 0; i < size; i++) {
        String val = (i % 5 == 0) ? null : "value" + i;
        map.put("key" + i, val);
    }

    long totalCallback = 0;
    long totalTryGet = 0;

    int iterations = 20;
    for (int i = 0; i < iterations; i++) {
        {
            TryGetExample tryGet = new TryGetExample(map);
            long tryGetStart = System.currentTimeMillis();
            tryGet.test();
            totalTryGet += (System.currentTimeMillis() - tryGetStart);
        }
        System.gc();

        {
            CallbackExample callback = new CallbackExample(map);
            long callbackStart = System.currentTimeMillis();
            callback.test();
            totalCallback += (System.currentTimeMillis() - callbackStart);
        }
        System.gc();
    }

    System.out.println("Avg. callback: " + (totalCallback / iterations));
    System.out.println("Avg. tryGet(): " + (totalTryGet / iterations));
}

С первой попытки я получил на 50% худшую производительность для обратного вызова, чем для tryGet(), что меня очень удивило. Но, догадываясь, я добавил сборщик мусора, и снижение производительности исчезло.

Это соответствует моему инстинкту, который заключается в том, что мы в основном говорим о том, чтобы брать одинаковое количество вызовов методов, проверок условий и т. Д. И переставлять их. Но затем я написал код, поэтому вполне мог бы написать неоптимальную или подсознательно оштрафованную реализацию tryGet(). Мысли?


Обновлено: За комментарий от Майкл Аарон Сафьян , исправлено TryGetExample для повторного использования OutParameter.

Ответы [ 2 ]

2 голосов
/ 21 апреля 2010

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

Фактическое использование
Если пользователь ищет значение на карте, а его там нет, скорее всего, пользователь хочет одно из следующего:

  • Чтобы вставить какое-то значение с этим ключом в карту
  • Чтобы вернуть какое-то значение по умолчанию
  • Чтобы быть в курсе, что значение не существует

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

  • has(key), который указывает, присутствует ли ключ (если нужно только проверить наличие ключа).
  • get(key), который сообщает значение, если ключ присутствует; в противном случае выдает NoSuchElementException.
  • get(key,defaultval), который сообщает значение для ключа, или defaultval, если ключ отсутствует.
  • setdefault(key,defaultval), который вставляет (ключ, defaultval), если ключ отсутствует, и возвращает значение, связанное с ключом (которое является значением defaultval, если нет предыдущего сопоставления, в противном случае прежнее сопоставление).

Единственный способ вернуть значение null - это если вы просите об этом, как в get (key, null). Этот API невероятно прост, и в то же время способен обрабатывать самые распространенные задачи, связанные с картой (в большинстве случаев, с которыми я сталкивался).

Я должен также добавить, что в Java has () будет вызываться containsKey (), а setdefault () будет вызываться putIfAbsent (). Поскольку get () сигнализирует об отсутствии объекта через NoSuchElementException, тогда можно связать ключ с нулем и рассматривать его как допустимую ассоциацию .... если get () возвращает ноль, это означает, что ключ был связан со значением null, а не то, что ключ отсутствует (хотя вы можете определить свой API, чтобы запретить значение null, если вы того пожелаете, и в этом случае вы бы сгенерировали исключение IllegalArgumentException из функций, которые используются для добавления ассоциаций, если значение равно нулю) , Еще одним преимуществом этого API является то, что setdefault () нужно выполнять процедуру поиска только один раз, а не дважды, что будет иметь место, если вы используете if (! Dict.has (key)) {dict.set (key, val) ; }. Еще одним преимуществом является то, что вы не удивляет разработчиков, которые пишут что-то вроде dict.get (key) .doSomething (), которые предполагают, что get () всегда будет возвращать ненулевой объект (потому что они никогда не вставляли нулевое значение в словарь). ... вместо этого они получают исключение NoSuchElementException, если для этого ключа нет значения, что более соответствует остальной части проверки ошибок в Java и также намного проще для понимания и отладки, чем NullPointerException.

Ответ на вопрос
Чтобы ответить на первоначальный вопрос, да, вы несправедливо наказываете версию tryGet .... в своем механизме обратного вызова вы создаете объект обратного вызова только один раз и используете его во всех последующих вызовах; в то время как в вашем примере tryGet вы создаете свой объект параметра out в каждой отдельной итерации. Попробуйте взять строку:

OutParameter out = new OutParameter();

Извлеките вышеприведенную строку из цикла for и посмотрите, не улучшит ли это производительность примера tryGet. Другими словами, поместите строку над циклом for и повторно используйте параметр out в каждой итерации.

1 голос
/ 21 апреля 2010

Дэвид, спасибо, что нашли время, чтобы написать это. Я программист на C #, поэтому мои навыки Java немного размыты в наши дни. Из-за этого я решил портировать ваш код и протестировать его сам. Я обнаружил некоторые интересные различия и сходства, которые, насколько я понимаю, стоят стоимости входного билета. Среди основных отличий:

  1. Мне не нужно было реализовывать TryGet, потому что он встроен в словарь.
  2. Чтобы использовать собственный TryGet, вместо вставки нулей для имитации промахов, я просто опустил эти значения. Это все еще означает, что v = map[k] установил бы v на null, поэтому я думаю, что это правильное портирование. Оглядываясь назад, я мог бы вставить нули и изменить (_map.TryGetValue(key, out value)) на (_map.TryGetValue(key, out value) && value != null)), но я рад, что не сделал.
  3. Я хочу быть чрезвычайно справедливым. Поэтому, чтобы сделать код максимально компактным и обслуживаемым, я использовал нотацию лямбда-исчисления, которая позволяет безболезненно определять обратные вызовы. Это скрывает большую часть сложности настройки анонимных делегатов и позволяет беспрепятственно использовать замыкания. По иронии судьбы, реализация Lookup использует TryGet для внутреннего использования.
  4. Вместо того, чтобы объявлять новый тип словаря, я использовал метод расширения, чтобы привить Lookup к стандартному словарю, значительно упрощая код.

С извинениями за не профессиональное качество кода, вот оно:

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

namespace ConsoleApplication1
{
    static class CallbackDictionary
    {
        public static void Lookup<K, V>(this Dictionary<K, V> map, K key, Action<K, V> found, Action<K> missed)
        {
            V v;
            if (map.TryGetValue(key, out v))
                found(key, v);
            else
                missed(key);
        }
    }

    class TryGetExample
    {
        private Dictionary<string, string> _map;
        private List<string> _found;
        private List<string> _missing;

        public TryGetExample(Dictionary<string, string> map)
        {
            _map = map;
            _found = new List<string>(_map.Count);
            _missing = new List<string>(_map.Count);
        }

        public void TestTryGet()
        {
            for (int i = 0; i < _map.Count; i++)
            {
                string key = "key" + i;
                string value;
                if (_map.TryGetValue(key, out value))
                    _found.Add(key + ": " + value);
                else
                    _missing.Add(key);
            }

            Console.WriteLine(_found.Count() + " found");
            Console.WriteLine(_missing.Count() + " missing");
        }

        public void TestCallback()
        {
            for (int i = 0; i < _map.Count; i++)
                _map.Lookup("key" + i, (k, v) => _found.Add(k + ": " + v), k => _missing.Add(k));

            Console.WriteLine(_found.Count() + " found");
            Console.WriteLine(_missing.Count() + " missing");
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            int size = 2000000;
            var map = new Dictionary<string, string>(size);
            for (int i = 0; i < size; i++)
                if (i % 5 != 0)
                    map.Add("key" + i, "value" + i);

            long totalCallback = 0;
            long totalTryGet = 0;

            int iterations = 20;
            TryGetExample tryGet;
            for (int i = 0; i < iterations; i++)
            {
                tryGet = new TryGetExample(map);
                long tryGetStart = DateTime.UtcNow.Ticks;
                tryGet.TestTryGet();
                totalTryGet += (DateTime.UtcNow.Ticks - tryGetStart);
                GC.Collect();

                tryGet = new TryGetExample(map);
                long callbackStart = DateTime.UtcNow.Ticks;
                tryGet.TestCallback();
                totalCallback += (DateTime.UtcNow.Ticks - callbackStart);
                GC.Collect();
            }

            Console.WriteLine("Avg. callback: " + (totalCallback / iterations));
            Console.WriteLine("Avg. tryGet(): " + (totalTryGet / iterations));
        }
    }
}

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

Отчасти проблема в том, что я использовал таймер низкой точности, и тест был коротким, поэтому я увеличил счет в 10 раз до 2000000, и это помогло. Теперь обратные вызовы примерно на 3% медленнее, что я не считаю значительным. На моей довольно медленной машине обратные вызовы заняли 17773437, а tryget - 17234375.

Теперь, что касается сложности кода, это немного несправедливо, потому что TryGet является нативным, поэтому давайте просто проигнорируем тот факт, что мне пришлось добавить интерфейс обратного вызова. В месте вызова лямбда-нотация отлично справилась с задачей скрыть сложность. Во всяком случае, он на самом деле короче, чем if / then / else, используемый в версии TryGet, хотя, полагаю, я мог бы использовать троичный оператор, чтобы сделать его одинаково компактным.

В целом я нашел C # более элегантным, и только отчасти это связано с моим предвзятым отношением к программированию на C #. Главным образом, мне не нужно было определять и реализовывать интерфейсы, что сокращало накладные расходы на сантехнику. Я также использовал довольно стандартные соглашения .NET, которые кажутся немного более обтекаемыми, чем стиль, предпочитаемый в Java.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...