Синтаксис точек и dig! для хэшей Ruby, сравнительный анализ


Оглавление
  • Базовые показатели
  • Точечный синтаксис
  • Dig с ошибками
  • Мораль истории
  • Приложение A: регулярный dig на хэше с ошибками по умолчанию
  • Приложение B: эталонный код

Недавно я услышал об одной удобной возможности карт Elixir:

Для доступа к ключам атомов можно также использовать нотацию map.key. Обратите внимание, что map.key выдаст KeyError, если map не содержит ключ :key, по сравнению с map[:key], который вернет nil.

Отлично! Это то, о чем я мечтал в Ruby. В текущем проекте у меня есть хэш конфигурации, который передается и используется в различных объектах. Хэш довольно большой и имеет несколько уровней, поэтому мой код изобилует цепочками Hash#fetch, такими как config.fetch(:item).fetch(:template).fetch(:variants). Что, как вы можете себе представить, делает некоторые строки очень длинными и не особо читабельными 😒.

Два замечания о том, почему я делаю это таким образом:

  • Причина, по которой я использую fetch вместо config[:item][:template][:variants] или config.dig(:item, :template, :variants) в том, что KeyError, выдаваемый fetch, в случае отсутствия ключа, более полезен, чем значение по умолчанию nil из скобок или dig. На самом деле, этот nil может стать причиной большой головной боли при отладке, если приведет к ошибке в другом месте, далеко от того, где был получен nil.
  • Если вам интересно, почему я использую необработанный хэш вместо пользовательского класса Config с синтаксическим сахаром, таким как config[:item, :template, :variants]: это может быть отличной идеей в некоторых проектах! Но в этом проекте некоторые объекты используют только часть конфига, и я не хочу передавать весь конфиг в эти объекты. Кроме того, некоторые объекты выполняют хэш-операции, используя части конфига. Так что если я создаю отдельные объекты Config только для того, чтобы обернуть внутренние хэши от основного Config, и если я конвертирую эти объекты Config в хэш в различных точках, то, кажется, я должен просто использовать хэш для начала. В этом проекте проще постоянно иметь дело с хэшами, чтобы не спрашивать себя: «Посмотрим, это объект Config здесь, или он превратился в хэш?».

Итак, если мы будем использовать необработанный хэш, можем ли мы взломать наш путь к более краткой альтернативе этим повторяющимся fetch, но с той же защитой от KeyError? Конечно! В конце концов, это Ruby, где возможно все. Но целесообразно ли это… вот в чем вопрос. В этом посте мы рассмотрим два возможных синтаксиса вместе с их последствиями для производительности и удобства использования:

  • Точечный синтаксис: config.item.template.variants.
  • Копать с ошибками: config.dig!(:item, :template, :variants)

Изначально я задался целью найти производительный подход к точечному синтаксису, но к концу я передумал, по причинам, которые я объясню.

Базовые показатели

Во-первых, здесь приведены эталоны стандартного синтаксиса (в основном). Код эталонных примеров приведен в конце этого сообщения.

  1. Скобочные обозначения: hash[:a][:b].
  2. Dig: hash.dig(:a, :b)
  3. Цепочка fetch: hash.fetch(:a).fetch(:b)
  4. Более короткий псевдоним fetch: hash > :a > :b. Потому что почему бы и нет.
                           user     system      total        real
1. brackets           :  0.003332   0.000000   0.003332 (  0.003332)
2. dig                :  0.002877   0.000823   0.003700 (  0.003704)
3. fetch              :  0.005040   0.000000   0.005040 (  0.005044)
4. fetch alias        :  0.005012   0.000000   0.005012 (  0.005012)
Вход в полноэкранный режим Выход из полноэкранного режима

Примечания:

  • Все эти функции очень эффективны. Но помните, что скобки и dig возвращают nil там, где мне нужен KeyError, а цепочка fetch — это то, от чего я пытаюсь уйти.
  • В некоторых случаях dig (#2) был быстрее, чем скобки (#1), но чаще скобки выигрывали на волосок.
  • В бенчмарках цепочка fetch (#3) постоянно медленнее скобок, но набор тестов моего проекта не работает быстрее, когда я заменяю все вызовы fetch скобками. Это хорошее напоминание о том, что бенчмарки не всегда отражают реальную производительность.
  • Несмотря на то, что псевдоним fetch (#4) работает так же быстро, как и сам fetch в бенчмарках, набор тестов моего проекта выполнялся на 20% дольше, когда я заменил все вызовы fetch псевдонимом. 20% медленнее — это не так много, особенно учитывая, что все мои тесты выполняются менее чем за одну секунду. Но есть еще тот факт, что хотя config > ... > ... выглядит действительно круто, он немного загадочен (вероятно, запутает моего будущего забывчивого себя), и мне приходится окружать его круглыми скобками каждый раз, когда я хочу вызвать метод на возвращаемом значении. Тем не менее, мне было интересно, как это отразится на производительности, и поэтому я включил сюда псевдоним fetch.

Синтаксис точки

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

  1. Faux dot notation путем сплющивания хэша и присвоения ему составных ключей, как в config[: "item.template.variants"]. Я скопировал этот подход отсюда, с той лишь разницей, что в качестве ключей я использую символы, поскольку они более производительны, чем строки. Обратите внимание, что : "string" похож на "string".to_sym, но быстрее, потому что строка не создается каждый раз. Также в этом подходе используются скобки, но только потому, что аксессор скобок хэша (Hash#[]) переопределен для использования fetch.
  2. OpenStruct, который иногда предлагается в подобных разговорах.
  3. Дополнение одного хэша методами, соответствующими его ключам.
  4. ActiveSupport::OrderedOptions.
  5. hash_dot gem. Кроме того, мой эталонный код основан на эталонах, приведенных в README hash_dot.
  6. Hashie gem.
                           user     system      total        real
1. flat composite keys:  0.003461   0.000000   0.003461 (  0.003461)
2. OpenStruct         :  0.009731   0.000000   0.009731 (  0.009772)
3. per-hash dot access:  0.015300   0.000000   0.015300 (  0.015304)
4. AS::OrderedOptions :  0.070637   0.000000   0.070637 (  0.070640)
5. hash_dot           :  0.163008   0.000000   0.163008 (  0.163076)
6. hashie             :  0.163450   0.000000   0.163450 (  0.163451)
Вход в полноэкранный режим Выход из полноэкранного режима

Примечания:

  • Некоторые подходы к точечной нотации связаны с более раздражающей настройкой, чем другие, и/или со значительными ограничениями. Например, сплющенный хэш с составными ключами (#1) очень быстр, но он далек от ванильного вложенного хэша, с которого я начинал. Это делает некоторые операции с хэшем более сложными, например, итерации по ключам хэша. Для моих целей это не стоит головной боли.
  • OpenStruct быстрее, чем я думал. Но его фатальные недостатки для моих целей заключаются в том, что он не является хэшем и поэтому не имеет большой функциональности, а также он не выдает ошибку при несуществующем атрибуте (как KeyError из fetch), а вместо этого возвращает nil.
  • Точечный доступ по хэшу (#3) — это самая быстрая истинная точечная нотация для хэша. (Обратите внимание, что это работает только для хэша, который не получает новых ключей после его создания, что как раз подходит для моего конфигурационного хэша). Однако, когда это было применено в моем проекте, это все равно заставило мои тесты выполняться на 70% дольше. Опять же, это не так плохо, как кажется, для моего набора тестов, выполняемых за 1 секунду.
  • Но как только я заменил вызовы fetch в моем проекте на точечную нотацию, произошло нечто неожиданное. Мой код выглядел более беспорядочным, хотя теперь он был более лаконичным. Причина, я думаю, в том, что больше не было множества (выделенных синтаксисом) символов в тех местах, где я обращаюсь к хэшу конфига, и поэтому было немного сложнее с первого взгляда увидеть, где используются значения конфига. Вместо ярких цветных символов, равномерно разделенных fetch, мои глаза теперь видели только кашу из вызовов методов, пока мой мозг не обработал слова и не сказал мне, является ли это местом, где происходит обращение к хэшу конфигурации. Хм. Теперь мне стало интересно, есть ли способ сохранить задействованные символы, но в более сжатом виде, чем цепочка fetch 🤔.

Копать с ошибками

Hash#dig выглядит красиво: hash.dig(:item, :template, :variants). Но опять же, проблема в том, что он по умолчанию выдает nil для несуществующих ключей. Что если бы мы могли сделать аналогичный метод, который вместо этого выдает KeyError?

На самом деле это уже несколько раз предлагалось в качестве дополнения к Ruby (1, 2, 3) с различными названиями, включая dig!, deep_fetch, и dig_fetch. Но этот метод вряд ли будет добавлен в ближайшем будущем. Так что… давайте сделаем это сами!

Вот несколько различных реализаций с примерами. Существует также пара гемов для этого, dig_bang и deep_fetch, но я не включил их сюда, потому что dig_bang использует reduce (#4 ниже), а deep_fetch использует рекурсию, которая выполняет то же самое.

  1. Регулярный dig на хэше, для которого были установлены значения по умолчанию, чтобы он выдавал ошибку при несуществующих ключах.
                           user     system      total        real
1. dig, error defaults:  0.003750   0.000000   0.003750 (  0.003750)
2. case_dig!          :  0.007850   0.000000   0.007850 (  0.007856)
3. while_dig!         :  0.014849   0.000000   0.014849 (  0.014852)
4. reduce_dig!        :  0.027889   0.000056   0.027945 (  0.027950)
Вход в полноэкранный режим Выход из полноэкранного режима

Примечания:

  • Самый простой подход — использовать обычный dig на хэше, которому были заданы KeyError-raising defaults. Это плохая идея, которую трудно объяснить в двух словах. Обратитесь к приложению A, если хотите знать.
  • продать душу обменяйте идиоматический Ruby на немного больше скорости.

Мораль истории

В середине всего этого я всерьез подумывал сдаться и просто вернуться к fetch, потому что он самый производительный, а любой другой синтаксис рискует сделать мой код более загадочным для моего будущего «я». Когда я вижу config.fetch(:item), я знаю, что имею дело с хэшем, в отличие от того, когда я вижу config.item. Я уверен, что даже config.dig!(:item, :template) заставит моего будущего «я» задуматься. Для меня эти затраты перевешиваются лучшей читабельностью, которую я получаю взамен, но удивительно, что именно это (а не производительность) стало причиной трудного решения.

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

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

Приложение A: регулярный dig на хэше с ошибками по умолчанию

Итак, почему это плохая идея? Она не несет никаких потерь в производительности, потому что это просто обычный dig на обычном хэше. Что может пойти не так?

Проблема в том, что в самом начале я должен изменить хэш конфигурации, чтобы задать ему значения по умолчанию, которые вызывают KeyError. Вспомните, что мне также пришлось модифицировать хэш, когда я пробовал точечный доступ по хэшу (#3 в контрольных примерах по точечному синтаксису выше). Но на этот раз модификация меня устраивает меньше, потому что она может «выскользнуть» менее заметными способами.

Например, если в какой-то момент в моем коде хэш конфига будет обработан таким образом, что создастся производный хэш (например, вызовом map и использованием результата), то этот производный хэш будет новым хэшем без KeyError по умолчанию.

Этот новый хэш может быть передан по кругу, при этом я буду думать, что это оригинал с особыми настройками по умолчанию. Я могу использовать dig на хэше, и dig будет работать как в любом хэше (без моего надежного KeyError), и я никогда не узнаю, что чего-то не хватает 💀.

Так что такой подход слишком хрупок, на мой вкус. К тому же, мое будущее «я» может задаться вопросом «Почему я использовал dig, а не fetch?», пока будущее «я» не обнаружит мой хак.

Приложение B: код бенчмарка

require 'benchmark'
require 'ostruct'
require 'active_support/ordered_options'
require 'hash_dot'
require 'hashie'
require 'active_support/core_ext/object/blank'

#### SETUP

## FOR BASELINE BENCHMARKS

# regular hash
vanilla = { address: { category: { desc: "Urban" } } }

# fetch alias
class Hash
  alias_method :>, :fetch
end

## FOR DOT BENCHMARKS

# a flattened hash with composite keys
# from https://snippets.aktagon.com/snippets/738-dot-notation-for-ruby-configuration-hash
def to_namespace_hash(object, prefix = nil)
  if object.is_a? Hash
    object.map do |key, value|
      if prefix
        to_namespace_hash value, "#{prefix}.#{key}".to_sym
      else
        to_namespace_hash value, "#{key}".to_sym
      end
    end.reduce(&:merge)
  else
    { prefix => object }
  end
end

flat = { address: { category: { desc: "Urban" } } }
flat = to_namespace_hash(flat)

def flat.[](key)
  fetch(key)
rescue KeyError => e
  possible_keys = keys.map { |x| x if x.match /.*?#{key}.*?/i }.delete_if(&:blank?).join("n")
  raise KeyError, "Key '#{key}' not found. Did you mean one of:n#{possible_keys}"
end

flat.freeze

# OpenStruct
ostruct = OpenStruct.new(
  address: OpenStruct.new(
    category: OpenStruct.new(
      desc: "Urban"
    )
  )
)

# per-hash dot access
def allow_dot_access(vanilla_hash)
  vanilla_hash.each do |key, value|
    vanilla_hash.define_singleton_method(key) { fetch(key) }
    if value.is_a?(Hash) then allow_dot_access(value); end
  end
end

# ActiveSupport::OrderedOptions
asoo = ActiveSupport::OrderedOptions.new
asoo.address = ActiveSupport::OrderedOptions.new
asoo.address.category = ActiveSupport::OrderedOptions.new
asoo.address.category.desc = "Urban"

# hash_dot gem
hash_dot = vanilla.to_dot

my_dot = allow_dot_access({ address: { category: { desc: "Urban" } } }).freeze

## FOR DIG! BENCHMARKS

# with error defaults
def add_key_error_defaults(vanilla_hash)
  vanilla_hash.default_proc = -> (_hash, key) { raise KeyError, "key not found: :#{key}" }
  vanilla_hash.values.each do |value|
    if value.is_a? Hash
      add_key_error_defaults(value)
    end
  end
  vanilla_hash
end

errorful = add_key_error_defaults({ address: { category: { desc: "Urban" } } })

# dig! implementations
class Hash
  # ewwwwwwwwwwwww
  def case_dig!(key1, key2 = nil, key3 = nil, key4 = nil)
    if key4
      fetch(key1).fetch(key2).fetch(key3).fetch(key4)
    elsif key3
      fetch(key1).fetch(key2).fetch(key3)
    elsif key2
      fetch(key1).fetch(key2)
    else
      fetch(key1)
    end
  end

  def while_dig!(*keys)
    hash = self
    while key = keys.shift
      hash = hash.fetch(key)
    end
    hash
  end

  def reduce_dig!(*keys)
    keys.reduce(self) { |memo, key| memo.fetch(key) }
  end
end

#### BENCHMARKS

iterations = 50000

Benchmark.bm(8) do |bm|
  puts "BASELINES:"

  bm.report("1. brackets           :") do
    iterations.times do
      vanilla[:address][:category][:desc]
    end
  end

  bm.report("2. dig                :") do
    iterations.times do
      vanilla[:address][:category][:desc]
    end
  end

  bm.report("3. fetch              :") do
    iterations.times do
      vanilla.fetch(:address).fetch(:category).fetch(:desc)
    end
  end

  bm.report("4. fetch alias        :") do
    iterations.times do
      vanilla > :address > :category > :desc
    end
  end

  puts "DOT:"

  bm.report("1. flat composite keys:") do
    iterations.times do
      flat["address.category.desc".to_sym]
    end
  end

  bm.report("2. OpenStruct         :") do
    iterations.times do
      ostruct.address.category.desc
    end
  end

  bm.report("3. per-hash dot access:") do
    iterations.times do
      my_dot.address.category.desc
    end
  end

  bm.report("4. AS::OrderedOptions :") do
    iterations.times do
      asoo.address.category.desc
    end
  end

  bm.report("5. hash_dot           :") do
    iterations.times do
      hash_dot.address.category.desc
    end
  end

  class Hash
    include Hashie::Extensions::MethodAccess
  end

  bm.report("6. hashie             :") do
    iterations.times do
      vanilla.address.category.desc
    end
  end

  puts "DIG!:"

  bm.report("1. dig, error defaults:") do
    iterations.times do
      errorful.dig(:address, :category, :desc)
    end
  end

  bm.report("2. case_dig!          :") do
    iterations.times do
      vanilla.case_dig!(:address, :category, :desc)
    end
  end

  bm.report("3. while_dig!         :") do
    iterations.times do
      vanilla.while_dig!(:address, :category, :desc)
    end
  end

  bm.report("4. reduce_dig!        :") do
    iterations.times do
      vanilla.reduce_dig!(:address, :category, :desc)
    end
  end
end
Вход в полноэкранный режим Выход из полноэкранного режима

Оцените статью
Procodings.ru
Добавить комментарий