Оглавление
- Базовые показатели
- Точечный синтаксис
- 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)
Изначально я задался целью найти производительный подход к точечному синтаксису, но к концу я передумал, по причинам, которые я объясню.
Базовые показатели
Во-первых, здесь приведены эталоны стандартного синтаксиса (в основном). Код эталонных примеров приведен в конце этого сообщения.
- Скобочные обозначения:
hash[:a][:b]
. - Dig:
hash.dig(:a, :b)
- Цепочка
fetch
:hash.fetch(:a).fetch(:b)
- Более короткий псевдоним
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
.
Синтаксис точки
Вот несколько подходов к точечной нотации для хэшей или хэш-подобных структур, проверенных бенчмарком. Помните, что я измерял только производительность доступа (чтения), а не инициализации или записи.
- Faux dot notation путем сплющивания хэша и присвоения ему составных ключей, как в
config[: "item.template.variants"]
. Я скопировал этот подход отсюда, с той лишь разницей, что в качестве ключей я использую символы, поскольку они более производительны, чем строки. Обратите внимание, что: "string"
похож на"string".to_sym
, но быстрее, потому что строка не создается каждый раз. Также в этом подходе используются скобки, но только потому, что аксессор скобок хэша (Hash#[]
) переопределен для использованияfetch
. - OpenStruct, который иногда предлагается в подобных разговорах.
- Дополнение одного хэша методами, соответствующими его ключам.
- ActiveSupport::OrderedOptions.
- hash_dot gem. Кроме того, мой эталонный код основан на эталонах, приведенных в README hash_dot.
- 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
использует рекурсию, которая выполняет то же самое.
- Регулярный
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