Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Ruby

Làm Chủ Memoization Trong Ruby: Tăng Hiệu Năng Với Kỹ Thuật Cache Đơn Giản

Hôm nay tôi muốn nói về một trong những kỹ thuật yêu thích của tôi để cải thiện hiệu năng. Đây là nguồn của những chiến thắng hiệu năng nhỏ dễ dàng cuối cùng cộng lại, và chỉ đôi khi làm ứng dụng của bạn thành đống tro tàn. Chỉ rất đôi khi.

Kỹ thuật này gọi là "memoization." Dù từ khoa học máy tính $10 này, nó đơn giản chỉ nghĩa là, thay vì làm cùng một công việc mỗi khi gọi một phương thức, bạn lưu giá trị trả về vào một biến và dùng biến đó thay thế.

Dưới đây là giao diện của nó trong pseudocode:

def my_method
  @memo = <work> if @memo is undefined
  return @memo
end

Và đây là cách làm trong Ruby. Đây là cách tiếp cận vững chắc nhất, nhưng nó khá verbose. Có các cách tiếp cận ngắn gọn hơn mà chúng ta sẽ thảo luận sau.

class MyClass
  def my_method
    unless defined?(@my_method)
      @my_method = begin
        # Làm tính toán, truy vấn database
        # hoặc công việc chạy lâu khác ở đây.
      end
    end
    @my_method
  end
end

Mã trên làm ba việc:

  1. Kiểm tra xem có instance variable tên @my_method không.
  2. Nếu có, nó làm một số việc và lưu kết quả trong @my_method.
  3. Nó trả về @my_method

Đừng bị nhầm lẫn vì chúng ta có cả một phương thức và một instance variable tên my_method. Tôi có thể đặt tên biến bất kỳ, nhưng quy ước là đặt tên theo phương thức đang được memoize.

Phiên bản viết tắt

Một vấn đề với mã trên là nó hơi cồng kềnh. Vì thế, bạn có nhiều khả năng thấy phiên bản viết tắt làm gần như cùng việc:

class MyClass
  def my_method1
    @my_method1 ||= some_long_calculation
  end
  def my_method2
    @my_method2 ||= begin
      # Khối begin-end cho phép bạn dễ dàng
      # dùng nhiều dòng mã ở đây.
    end
  end
end

Cả hai đều dùng toán tử a ||= b của Ruby, là viết tắt cho a || (a = b), mà bản thân nó là viết tắt cho:

# Bạn không dùng return như vậy trong đời thực.
# Tôi chỉ dùng nó để biểu đạt ý tưởng cho người mới
# rằng điều kiện evaluate thành bất cứ gì kết thúc trong `a`.
if a
  return a
else
  a = b
  return a
end

Nguồn gốc của các bug

Nếu bạn chú ý rất kỹ, bạn có thể nhận thấy phiên bản viết tắt đánh giá "truthiness" của biến memo thay vì kiểm tra sự tồn tại của nó. Đây là nguồn gốc của một trong những hạn chế chính của phiên bản viết tắt: nó không bao giờ memoize nil hoặc false.

Có rất nhiều trường hợp sử dụng nơi điều này không quan trọng. Nhưng đó là một trong những sự thật phiền toái bạn phải giữ ở cuối đầu mỗi khi bạn đang memoize.

Memoize các phương thức có tham số

Cho đến nay chúng ta chỉ xử lý memoize giá trị đơn lẻ. Nhưng không nhiều hàm trả về cùng kết quả mọi lúc. Hãy xem một món quà cũ của các buổi phỏng vấn kỹ thuật: dãy Fibonacci.

Bạn có thể tính dãy Fibonacci đệ quy trong Ruby như sau:

class Fibonacci
  def self.calculate(n)
    return n if n == 0 || n == 1
    calculate(n - 1) + calculate(n - 2)
  end
end
Fibonacci.calculate(10) # => 55

Vấn đề với triển khai này là nó không hiệu quả. Để chứng minh, hãy thêm câu lệnh print để xem giá trị của n.

class Fibonacci
  def self.calculate(n)
    print "#{ n } "
    return n if n == 0 || n == 1
    calculate(n - 1) + calculate(n - 2)
  end
end
Fibonacci.calculate(4)
# Outputs: 4 3 2 1 0 1 2 1 0

Như bạn thấy, calculate được gọi liên tục với nhiều giá trị n giống nhau. Thực tế số lần gọi calculate sẽ tăng theo cấp số nhân với n.

Một cách giải quyết là memoize kết quả của calculate. Làm vậy không quá khác với các ví dụ memoization chúng ta đã xem.

class Fibonacci
  def self.calculate(n)
    @calculate ||= {}
    @calculate[n] ||= begin
      print "#{ n } "
      if n == 0 || n == 1
        n
      else
        calculate(n - 1) + calculate(n - 2)
      end
    end
  end
end
Fibonacci.calculate(4)
# Outputs: 4 3 2 1 0

Bây giờ chúng ta đã memoize calculate, số lần gọi không còn tăng theo cấp số nhân với n.

Fibonacci.calculate(20)
# Outputs: 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0

Vô hiệu hóa

Memoization rất giống caching, trừ rằng kết quả memoized không tự động hết hạn, và thường không có cách dễ dàng để xóa chúng một fois đã set.

Đối với các trường hợp sử dụng như generator dãy Fibonacci, điều này không quá quan trọng. Fibonacci.calculate(10) sẽ luôn trả về cùng kết quả. Nhưng trong các trường hợp khác thì có.

Ví dụ, bạn có thể thấy mã như sau:

# Không phải ý tưởng tốt
class User
  def full_name
    @full_name ||= [first_name, last_name].join(" ")
  end
end

Cá nhân tôi, tôi không dùng memoization ở đây vì nếu first hoặc last name thay đổi, full name có thể không được cập nhật.

Nơi duy nhất bạn có thể hơi lỏng lẻo là bên trong Rails controllers. Rất phổ biến thấy mã như sau:

class ApplicationController
  def current_user
    @current_user ||= User.find(...)
  end
end

Điều này ổn, vì instance controller bị hủy sau mỗi web request. Không có khả năng user đăng nhập hiện tại thay đổi trong bất kỳ request bình thường nào.

Khi xử lý các kết nối streaming như ActionCable, bạn có thể cần cẩn thận hơn. Tôi không biết. Tôi chưa bao giờ dùng nó.

Lạm dụng

Cuối cùng tôi cảm thấy nên chỉ ra rằng như bất cứ thứ gì, có thể lạm dụng memoization quá mức. Đây là kỹ thuật thực sự chỉ nên áp dụng cho các thao tác tốn kém sẽ không bao giờ thay đổi trong suốt vòng đời của biến memo.