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:
- Kiểm tra xem có instance variable tên
@my_methodkhông. - Nếu có, nó làm một số việc và lưu kết quả trong
@my_method. - 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.