Ngày nay, việc thấy #freeze được sử dụng trong mã Ruby khá phổ biến. Nhưng thường không hoàn toàn rõ RẤT freeze được dùng. Trong bài này, chúng ta sẽ xem xét các lý do phổ biến nhất mà một lập trình viên có thể freeze các biến. Để minh họa cho từng lý do, tôi đã trích xuất mã ví dụ từ codebase Rails và các dự án open-source phổ biến khác.
Tạo hằng số bất biến
Trong Ruby, hằng số là có thể biến đổi. Hơi gây nhầm lẫn, nhưng mã rất dễ hiểu. Ở đây, tôi đã tạo một hằng số chuỗi và nối thêm một chuỗi khác vào nó.
MY_CONSTANT = "foo"
MY_CONSTANT << "bar"
puts MY_CONSTANT.inspect # => "foobar"
Bằng cách sử dụng #freeze, tôi có thể tạo một hằng số thực sự là hằng số. Lần này, khi cố gắng sửa đổi chuỗi, tôi nhận được RuntimeError.
MY_CONSTANT = "foo".freeze
MY_CONSTANT << "bar" # => RuntimeError: can't modify frozen string
Đây là ví dụ thực tế trong codebase ActionDispatch. Rails ẩn dữ liệu nhạy cảm trong log bằng cách thay thế bằng văn bản "[FILTERED]". Văn bản này được lưu trữ trong một hằng số frozen.
module ActionDispatch
module Http
class ParameterFilter
FILTERED = '[FILTERED]'.freeze
...
Giảm việc cấp phát đối tượng
Một trong những điều tốt nhất bạn có thể làm để tăng tốc ứng dụng Ruby là giảm số lượng đối tượng được tạo. Một nguồn gây cấp phát đối tượng phiền toái đến từ các string literals rải rác khắp hầu hết các ứng dụng.
Mỗi khi bạn thực hiện gọi phương thức như log("foobar"), bạn tạo một đối tượng String mới. Nếu mã của bạn gọi phương thức như vậy hàng nghìn lần mỗi giây, đó nghĩa là bạn đang tạo (và garbage-collect) hàng nghìn chuỗi mỗi giây. Đó là rất nhiều overhead!
May mắn thay, Ruby cho chúng ta một lối thoát. Nếu chúng ta freeze string literals, trình thông dịch Ruby sẽ chỉ tạo một đối tượng String và cache nó để sử dụng sau. Tôi đã chuẩn bị một benchmark nhanh so sánh hiệu năng của đối số string frozen vs non-frozen. Nó cho thấy tăng hiệu năng khoảng 50%.
require 'benchmark/ips'
def noop(arg) end
Benchmark.ips do |x|
x.report("normal") { noop("foo") }
x.report("frozen") { noop("foo".freeze) }
end
# Kết quả với MRI 2.2.2:
# Calculating -------------------------------------
# normal 152.123k i/100ms
# frozen 167.474k i/100ms
# -------------------------------------------------
# normal 6.158M (± 3.3%) i/s - 30.881M
# frozen 9.312M (± 3.5%) i/s - 46.558M
Bạn có thể thấy điều này trong hành động nếu nhìn vào router của Rails. Vì router được dùng cho mọi request trang web, nó cần nhanh. Nghĩa là rất nhiều frozen string literals.
# trích xuất từ https://github.com/rails/rails/blob/f91439d848b305a9d8f83c10905e5012180ffa28/actionpack/lib/action_dispatch/journey/router/utils.rb#L15
def self.normalize_path(path)
path = "/#{path}"
path.squeeze!('/'.freeze)
path.sub!(%r{/+\Z}, ''.freeze)
path.gsub!(/(%[a-f0-9]{2})/) { $1.upcase }
path = '/' if path == ''.freeze
path
end
Tối ưu hóa có sẵn trong Ruby >= 2.2
Ruby 2.2 và mới hơn (MRI) sẽ tự động freeze string literals được dùng làm khóa hash.
user = {"name" => "george"}
# Trong Ruby >= 2.2
user["name"] # ...tương đương với này, trong Ruby <= 2.1
user["name".freeze]
Và theo Matz, tất cả string literals sẽ được freeze tự động trong Ruby 3.
link: DevelopersMeeting20150820Japan - String literals are frozen (immutable) by default in Ruby 3.0
https://t.co/4XcelftmSa
— Yukihiro Matsumoto (@yukihiro_matz) August 20, 2015
Value objects & lập trình hàm
Mặc dù Ruby không phải ngôn ngữ lập trình hàm, nhiều Rubyists đã bắt đầu thấy giá trị của việc làm việc theo phong cách hàm. Một trong những nguyên tắc chính của phong cách này là bạn nên tránh side effects. Các đối tượng không bao giờ nên thay đổi sau khi đã được khởi tạo.
Bằng cách gọi phương thức freeze bên trong constructor, có thể đảm bảo một đối tượng sẽ không bao giờ thay đổi. Bất kỳ side-effect không mong muốn nào sẽ dẫn đến việc ném exception.
class Point
attr_accessor :x, :y
def initialize(x, y)
@x = x
@y = y
freeze
end
def change
@x = 3
end
end
point = Point.new(1,2)
point.change # RuntimeError: can't modify frozen Point