[Ruby] Ruby Memoization 指南

Ruby Memoization 指南

原文链接 A Guide to Memoization in Ruby | AppSignal Blog - https://blog.appsignal.com/2022/12/20/a-guide-to-memoization-in-ruby.html

中文翻译已获得 AppSignal - https://www.appsignal.com/Abiodun Olowode - https://blog.appsignal.com/authors/abiodun-olowode 授权。

Memoization 是一种缓存技术,可以使您的 Ruby 应用程序运行得更高效、更快。

在本文中,我们将探讨记忆化的好处以及何时在您的 Ruby 应用程序中使用它。我们还将研究一些要避免的 Memoization 使用错误。

让我们首先从代码优化开始——它是什么以及一些不同的可用优化技术。

什么是代码优化?

代码优化是提高代码质量以使一段代码或程序更高效和更实用的过程。它的优势包括——但不限于:

  • 在昂贵的计算中减少内存消耗

  • 执行速度快得多

  • 有时,代码库的空间更少

在应用程序的生命周期中,有时会出现实现上述某些目标的需求。如果您不知道从哪里开始代码优化,请尝试 Profiling!

什么是 Profiling?

Profiling 是指分析程序以测量其空间和时间复杂性。通过分析,我们可以获得以下信息:

  • 函数调用的频率和持续时间

  • 与其他函数相比,一个函数所花费的程序执行时间百分比

  • 每个函数的调用栈

  • 成功加载 HTML 页面需要多少次数据库调用以及需要多长时间

这些信息可以指导我们找到非常需要代码优化的地方。

Ruby 和 Rails 的代码优化方法

一些 Ruby 和 Rails 代码优化技术包括:

Ruby Memoization 简介

Memoization 是缓存方法结果的行为,以便下次调用该方法时,返回先前的结果(与执行重新计算相反)。这有助于在运行程序时节省时间。

让我们看一个涉及字谜的例子。

在下面的类中,我们创建了一个字典来将任何单词作为参数传递,并找到字谜。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Dictionary
def words
puts "creating my dictionary"
words = File.readlines('/usr/share/dict/words')
dictionary = Hash.new {|h,k| h[k] = []}
words.each do |word|
word = word.chomp
dictionary[word.chars.sort.join("")] = word
end
dictionary
end

def check(word)
words[word.chars.sort.join("")]
end
end

测试上面的类,我们得到:

1
2
3
4
5
6
7
dictionary = Dictionary.new
dictionary.check('rasp')
# creating my dictionary
=> "spar"
dictionary.check('kame')
# creating my dictionary
=> "make"

我们可以看到,每次调用check方法,我们都会重新创建字典。这绝对不是最优的,因为字典不会改变。

如果我们只创建一次字典并在需要时使用它会怎么样?我们可以; 使用 Memoization 。Memoization 允许我们缓存之前创建的字典。

1
2
3
4
5
6
7
8
9
10
11
12
def words
@words ||= begin
puts "creating my dictionary"
words = File.readlines('/usr/share/dict/words')
dictionary = Hash.new {|h,k| h[k] = []}
words.each do |word|
word = word.chomp
dictionary[word.chars.sort.join("")] = word
end
dictionary
end
end

测试以上,我们得到:

1
2
3
4
5
6
7
dict.check('eat')
#creating my dictionary
=> "tea"
dict.check('kame')
=> "make"
dict.check('live')
=> "vile"

如我们所见,字典创建一次,之后使用缓存版本。

让我们做一个基准测试,看看我们的程序因为 Memoization 而变得有多快。命名 memoized_checkcheck

1
2
3
4
require "benchmark"
dictionary = Dictionary.new
puts Benchmark.measure { 10.times { dictionary.check('rasp') } }
puts Benchmark.measure { 10.times { dictionary.memoized_check('rasp') } }

我们得到以下结果:

1
2
5.771061   0.044656   5.815717 (  5.836218)
0.563966 0.000016 0.563982 ( 0.564909)

这向我们展示了未记忆的版本需要5.83几秒钟,而记忆的版本需要几0.56秒钟,速度快了大约十倍。

在 Ruby 应用程序中要避免的 Memoization 错误

现在让我们看看在使用 Memoization 时要避免的一些错误。

忽略 False 或 Nil 返回

在方法的计算返回 false 或 nil 的情况下,无论何时调用该方法(即使 Memoization),每次调用都会导致重新计算。这是因为比较是使用or— 进行的,而在 Ruby 中,nilfalse都是false值。

1
2
3
4
5
6
2 || 4+5
=> 2
nil || 4+5
=> 9
false || 4+5
=> 9

Memoization using||=不考虑 false/nil 返回值,所以应该处理这种情况。

1
2
3
4
5
6
7
8
def do_computation
puts "I am computing"
nil
end

def check
@check ||= do_computation
end

调用check方法,我们得到如下结果:

1
2
3
4
5
6
check
# I am computing
=> nil
check
# I am computing
=> nil

为了解决这个问题,我们可以确定变量是否已经定义。如果是这样,我们会在继续计算之前提前返回。

1
2
3
4
def check
return @check if defined?(@check)
@check ||= do_computation
end

这导致:

1
2
3
4
5
check
# I am computing
=> nil
check
=> nil

将参数传递给方法

另一个常见的错误是假设将参数传递给方法时,Memoization将以不同的方式工作。

假设方法的结果是使用 Memoization 的||=,但该结果取决于参数。如果这些参数改变,结果不会改变。

1
2
3
def change_params(num)
@params ||= num
end

让我们看看这里发生了什么:

1
2
3
4
change_params(4)
=> 4
change_params(8)
=> 4

结果不会因为我们改变了参数而改变,坦率地说,考虑到 Memoization 的基本形式,这是预期的结果。

要处理此类情况,您必须熟悉:

  • 将参数存储为散列中的键
  • 使用考虑所有不同情况的 gem 或模块来处理

使用哈希:

1
2
3
4
5
6
7
8
9
def change_params(num)
@params_hash ||= {}
if (@params_hash.has_key?(num))
@params_hash[num]
else
puts 'creating a new key-value pair'
@params_hash[num] = num
end
end

尝试一下,我们有:

1
2
3
4
5
6
7
8
9
10
change_params(4)
creating a new key-value pair
=> 4
change_params(8)
creating a new key-value pair
=> 8
change_params(4)
=> 4
change_params(8)
=> 8

重写它的另一种方法如下:

1
2
3
4
def change_params(num)
@params_hash ||= {}
@params_hash[num] ||= num
end

或者你可以使用 gem Memoist - https://github.com/matthewrudy/memoist。考虑到传递的参数,它处理缓存方法的结果。它还提供了一种刷新对象的当前值或整个缓存的方法。

何时 Memoization ——何时不 Memoization

要决定何时进行 Memoization,请注意以下几点:

昂贵的操作

假设一个昂贵的操作肯定会在一个类中多次调用并返回相同的结果。

我们可以将其移动到对象实例化时初始化的实例变量中(此时也完成了昂贵的操作)。

1
2
3
4
5
attr_reader :result

def initialize
@result = do_expensive_calculation
end

result在类实例的整个生命周期中都可用,昂贵的计算只进行一次。

在这种情况下,我们不需要单独的方法来记忆do_expensive_calculation值。

一个可能不会发生的昂贵计算——但如果发生了,可能会发生不止一次(并返回相同的值)——是 Memoization 的一个很好的候选者。这意味着我们只do_expensive_calculation在需要时才缓存结果。

1
2
3
def expensive_calculation
@expensive_calculation ||= do_expensive_calculation
end

分析潜在的性能改进

只有在我们进行了性能分析之后,才可能认为记忆是必要的。我们需要准确地确定实施的记忆实际上提高了性能(就像我们在Dictionary课堂上所做的那样)。

否则,我们可能会向代码库添加不必要的复杂性。确保与运行时的收益相比,记忆化引起的空间和代码复杂性较低。

更改参数

如果用于计算的参数不断变化,则记忆化不是一个好的选择。

记忆化更适合纯函数,其返回值对于同一组参数是相同的。

1
2
3
def calculation(a, b)
a + b + Time.now.to_i
end

在上面的示例中,假设我们确实缓存了方法结果。每次我们调用该方法时,我们缓存的值都是错误的,因为发生了Time.now变化。

总结

在这篇文章中,我们探讨了 Memoization,就像每一种缓存技术一样,都有它的优点和缺点。在深入研究涉及 Memoization 的示例之前,我们首先研究了一系列代码优化技术。然后我们谈到了一些需要注意的错误。最后,我们探讨了 Memoization 何时有益以及何时避免。

当对类实例可用的方法执行 Memoization 时,Memoization 的结果仅在该对象的生命周期内可用。如果多个类实例(例如,多个 Web 请求)的结果相同,则类级别的 Memoization 通常是首选。

但是,这可能会在缓存失效方面增加更多的复杂性。使用缓存存储可能是缓存的更好替代方案,并且可以实现更好的优化。

在您决定使用它之前,您必须确定 Memoization 对于您的特定用例的利大于弊。

快乐记忆!

PS 如果您想在 Ruby Magic 发布后立即阅读它们,请订阅我们的 Ruby Magic 时事通讯,不要错过任何一篇文章 - https://blog.appsignal.com/ruby-magic

Abiodun Olowode

我们的客座作者 Abiodun 是一名使用 Ruby/Rails 和 React 的软件工程师。她热衷于通过写作/口语分享知识,并在空闲时间唱歌、狂看电影和观看足球比赛。

Abiodun Olowode 的所有文章 - https://blog.appsignal.com/authors/abiodun-olowode

参考链接

[1] charkost/prosopite: Rails N+1 queries auto-detection with zero false positives / false negatives - https://github.com/charkost/prosopite

[2] flyerhzm/bullet: help to kill N+1 queries and unused eager loading - https://github.com/flyerhzm/bullet

[3] Tools to help you detect n+1 queries - https://bhserna.com/tools-to-help-you-detect-n-1-queries.html

[4] RuboCop | The Ruby Linter/Formatter that Serves and Protects - https://rubocop.org/

[5] whitesmith/rubycritic: A Ruby code quality reporter - https://github.com/whitesmith/rubycritic

[6] matthewrudy/memoist: ActiveSupport::Memoizable with a few enhancements - https://github.com/matthewrudy/memoist

[7] AppSignal Blog atom feed | AppSignal Blog - https://blog.appsignal.com/ruby-magic