Các vấn đề cần chú ý khi lập trình meta trong Ruby
Bài đăng này đã không được cập nhật trong 7 năm
Tài liệu: Things to Consider when Metaprogramming in Ruby
Lập trình meta trong Ruby là một chủ đề phân cực. Mục đích chính của lập trình meta trong ruby là việc viết code sinh ra code ở thời điểm đang chạy. Lập trình meta làm cho code ngắn gọn và linh hoạt hơn. Tuy nhiên, nó không phải là không có chi phí. Đối với hầu hết mọi thứ, không có gía trị là free, thậm chí là metaprogramming.
Chắc chắn rằng sẽ có cơ hội và chỗ đứng thích hợp cho metaprogramming, nhưng nhận thấy sự nhượng bộ là cần thiết để hỗ trợ cho metaprogramming solutions là điều quan trọng.
Khả Năng Đọc và tìm hiểu Code Một vấn đề với giải pháp lập trình meta đó là trở ngại trong việc tìm hiểu code. Khi bước vào một dự án mới hay đơn giản chỉ là làm quen với các thứ tồn tại, tìm sự thực thi code trong text editor có thể hoàn toàn khó khăn nếu phương thức được định nghĩa không tồn tại.
Cho ví dụ, Chúng ta có thể đảm bảo rằng một lớp User tồn tại tập các phương thức với lập trình meta:
class User
[
:password,
:email,
:first_name,
:last_name
].each do |attribute|
define_method(:"has_#{attribute}?") do
self.send(attribute).nil?
end
end
end
Mặc dù có một ít sự sắp xếp trước, code này là một danh sách các phương thức đơn giản trên lớp User. Giải pháp này dễ dàng được mở rộng để include thêm các thuộc tính mà chưa định nghĩa phương thức đầy đủ trên mỗi thuộc tính.
Tuy nhiên, các phương thức đó không thể tìm kiếm sử dụng grep
, silver searcher, hay các công cụ "find all" khác. Do đó phương thức has_password?
không bao giờ được định nghĩa tường minh trong code. Nó không thể tìm thấy được.
Một vấn đề nữa là một số developer chọn viết ghi chú(comment) các phương thức được định nghĩa trên khối metaprogramming. Giải pháp đơn giản này là tuyệt vời để giúp cho code dễ đọc hơn:
class User
# has_password?, has_email?, has_first_name?,
# has_last_name? method definitions
[
:password,
:email,
:first_name,
:last_name
].each do |attribute|
define_method(:"has_#{attribute}?") do
self.send(attribute).nil?
end
end
end
Hiệu suất (Performance) Phụ thuộc vào tổng số lần một phần code được thực thi, sự xem xét đến hiệu suất là vô cùng quan trọng. "Hot Code" là một thuật ngữ để mô tả code mà được gọi thường xuyên trong suốt chu trình request ứng dụng. Do đó không phải tất cả code được tạo bằng nhau, hiểu về hiệu suất kéo theo các tiếp cận metaprogramming khác nhau là cần thiết khi viết hay chỉnh sửa hot code.
The Setup: Một ứng dụng ví dụ cần xử lý dữ liệu đầu vào mở rộng. Trong lúc tiếp nhận dữ liệu nó tạo ra một truy cập tới môt phần của ứng dụng. Có vô số tùy chọn tồn tại để giải quyết vấn đề naỳ, nhưng chúng ta có thể đảm bảo rằng chỉ cố một vài cái là có thể thực hiện được cho Ruby codebase.
Dự liệu đầu vào như sau:
{
"user": {
"name": "Some User",
"phones": [
"818-555-5555",
"415-555-5555"
],
"email": "email@whatever.com",
"birthday": "12-12-1900"
}
}
Chú ý: Dữ liệu này sẽ liên quan tới các vi dụ tiếp theo gọi nó là incoming_data
và chúng ta đã chuyển đổi nó từ JSON
thành Ruby hash
.
1. All Methods:
Một cách để chấp nhận và tích hợp dữ liệu đầu vào này là tạo ra một lớp để gắn tất cả các thuộc tính dưới key 'user'
tới các phương thức.
class UserMetaMethods
def initialize(hash)
hash.each_pair do |key, value|
self.class.send(:attr_accessor, key)
self.send(:"#{key}=", value)
end
end
end
user = UserMetaMethods.new(incoming_data['user'])
user.email
# => email@whatever.com
Giải pháp này làm ra các truy cập tới dữ liệu đầu vào rất tốt. Tất cả các thuộc tính xuất hiện giống như là các phương thức được trả về hoàn toàn với respond_to?
và có biến đối tượng tương thích mỗi thuộc tính.
2. method_missing
Một nhóm các giải pháp lập trình meta có thể không được thực hiện mà không sử dụng method_missing
. Với method_missing
, một lời gọi phương thức non-existent có thể bị chặn trên đối tượng và được ước lượng thêm dữ liệu không biết tới hàm gọi gốc.
class UserMethodMissing
def initialize(hash)
@hash = hash
end
def method_missing(method_name, *arguments, &block)
key = method_name.to_s
if @hash.key?(key)
@hash[key]
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
@hash.key?(method_name.to_s) || super
end
end
user = UserMethodMissing.new(incoming_data['user'])
user.email
# => email@whatever.com
Phương thức respond_to_missing?
cũng được định nghĩa để cho phép respond_to?
và method
gọi thực thi thành công. Tìm hiểu thêm về respond_to_missing?
ở đây.
Chú ý: Các Pattern tương tự điều này được sử dụng trong một số thư viện phổ biến như là OpenStruct
và Hashie
để lấy ra các kết quả tương tự.
3. “Regular” Object Với tư cách là một control, một đối tượng regular Ruby được tạo với các thuộc tính cụ thể được định nghĩa:
class UserRegular
attr_reader :name,
:phones,
:email,
:birthday
def initialize(hash)
@name = hash['name']
@phones = hash['phones']
@email = hash['email']
@birthday = hash['birthday']
end
end
user = UserRegular.new(incoming_data['user'])
user.email
# => email@whatever.com
Nhược điểm của cách tiếp cận này đó là: Nếu sự ràng buộc của dịch vụ bên ngoài thay đổi đối tượng này có thể không được khởi tạo với tất cả dữ liệu thích hợp.
4. A Hash
Không thêm code được yêu cầu cho tiếp cận này, một consumer có thể đơn giản sử dụng kết quả Ruby Hash
sau khi tiếp nhận JSON
đã được phân tích.
incoming_data['user']['email']
# => email@whatever.com
Không phải là một giải pháp lập trình meta, nhưng vẫn là một cách hợp lệ để xử lý việc truyền dữ liệu. Sử dụng một Hash
đơn giản không tạo ra sự linh động cho các giải pháp khác nhưng có thể là một trường hợp cơ bản tuyệt vời cho việc kiểm tra hiệu suất.
Làm thế nào để so sánh Cuối cùng, phần thú vị nhất là: performance benchmarks
Thư viện chúng ta sẽ sử dụng để test các giải pháp là: benchmark/ips
.
Thư viện này đơn giản chỉ khai báo các phần cài đặt khác nhau và sau đó so sánh chúng.
require 'benchmark/ips'
Benchmark.ips do |x|
x.report('UserMetaMethods') do
1000.times do
u = UserMetaMethods.new(incoming_data['user'])
u.email
end
end
x.report('UserMethodMissing') do
1000.times do
u = UserMethodMissing.new(incoming_data['user'])
u.email
end
end
x.report('UserRegular') do
1000.times do
u = UserRegular.new(incoming_data['user'])
u.email
end
end
x.report('Hash') do
1000.times do
u = Hash(incoming_data['user'])
u['email']
end
end
x.compare!
end
Mỗi report
tương ứng một giải pháp được mô tả ở trên. MộtHash
report không cần khởi tạo một đối tượng mới, nhưng vì mục đích nhất quán, một Hash
mới được khởi tạo từ tất cả mọi thứ dưới key 'user'
của Hash gốc.
Kết quả chạy code này:
Calculating -------------------------------------
UserMetaMethods 79.294 (± 2.5%) i/s - 399.000
UserMethodMissing 1.531k (± 1.2%) i/s - 7.791k
UserRegular 913.295 (± 1.4%) i/s - 4.628k
Hash 3.141k (± 1.0%) i/s - 15.860k
Comparison:
Hash: 3141.2 i/s
UserMethodMissing: 1530.5 i/s - 2.05x slower
UserRegular: 913.3 i/s - 3.44x slower
UserMetaMethods: 79.3 i/s - 39.61x slower
Wow! chúng ta thấy ở trên sử dụng Hash
, method_missing
là nhanh nhất và tất cả mọi người sẽ đi thay đổi tất cả code sử dụng method_missing !không, dừng lại không nên làm điều này.
Trong khi nó có thể nhanh hơn UserRegular
, nó không phải là không có nhược điểm của nó. Một giải pháp method_missing
tất nhiên có giá trị khác nhau trong mỗi trường hợp khác nhau nhưng với một cái benchmark đơn giản như vậy không đủ thuyết phục bất cứ ai chuyển code dựa trên tốc độ cả.
Còn về các thư viện tồn tại mà có các hành vi tương tự với method_missing
(ví dụ Hashie
và OpenStruct
) thì sao?
Để thêm chúng tới benchmark tồn tại, các lớp tương thích được tạo như sau:
require 'ostruct'
class UserOpenStruct < OpenStruct
end
require 'hashie'
class UserMash < Hashie::Mash
end
Sau đó hai hàm gọi report mới có thể thêm chúng tới benchmark tồn tại
Benchmark.ips do |x|
# ...
x.report('OpenStruct') do
1000.times do
u = UserOpenStruct.new(incoming_data['user'])
u.email
end
end
x.report('UserMash') do
1000.times do
u = UserMash.new(incoming_data['user'])
u.email
end
end
# ...
end
Kết quả của hai cái thêm vào có một ít ngạc nhiên:
Calculating -------------------------------------
UserMetaMethods 79.050 (± 2.5%) i/s - 399.000
UserMethodMissing 1.537k (± 1.3%) i/s - 7.752k
UserRegular 914.824 (± 1.4%) i/s - 4.576k
OpenStruct 49.954 (± 6.0%) i/s - 250.000
UserMash 194.411 (± 1.5%) i/s - 988.000
Hash 3.140k (± 0.9%) i/s - 15.759k
Comparison:
Hash: 3140.1 i/s
UserMethodMissing: 1536.9 i/s - 2.04x slower
UserRegular: 914.8 i/s - 3.43x slower
UserMash: 194.4 i/s - 16.15x slower
UserMetaMethods: 79.0 i/s - 39.72x slower
OpenStruct: 50.0 i/s - 62.86x slower
Mặc dù OpenStruct
và Hashie
dường như rất giống với giải pháp method_missing
, cả hai đều có kết quả xấu. Tuy nhiên, giống với các giải pháp lập trình meta khác, cả OpenStruct
và Hashie
bù lại cho sự thiếu sót về mặt tốc độ này với sự linh hoạt.
Nếu đây là một vấn đề thực trong ứng dụng production,OpenStruct
và Hashie
tất nhiên cả hai có thể là một giải pháp có thể tồn tại. Ngoài ra code path để sử dụng các thư viện là rất hot, vấn đề hiệu suất của chúng không phải là một nhân tố.
Tại sao tốc độ chậm? Giải pháp lập trình meta chậm là do một phần được làm với Ruby inline method cache. Trong Ruby, inline method cache chịu trách nhiệm lưu trữ các phương thức mà nó đã thực hiện để tránh phải tìm kiếm và thực hiện lại mỗi lần. Sự trở ngại của lập trình meta với cache xây dựng sẵn này bởi cache key không hợp lệ của nó.
Mỗi lần một lớp được mở lại hay một phương thức được định nghĩa trên một lớp, một phần của inline method cache key thay đổi, kết quả không được tìm thấy trong cache và nó phải tìm kiếm method. Metaprogrammed code (đặc biệt là code mà thực thi ở mỗi Object.new
giống như UserMetaMethod
) không có lợi đối với inline method caching nó giống như các cách mà code thông thường làm. Để tìm hiểu chi tiết về Ruby inline method caching xem ở đây.
Sử dụng Tool thích hợp cho Job
Khi lặp qua danh sách các lựa chọn, không có một data point nào đủ để xác định một lựa chọn là tốt nhất so với các lựa chọn khác. Benchmarks giống như một data point và mang độ sâu để so sánh, không có quy luật cho nó. Sau tất cả, ai quan tâm một phần của code chậm là bao lâu nếu nó không bao giờ chạy?
Metaprogramming là một tool mạnh mẽ trong ngôn ngữ ruby mà nên sử dụng. Giống như bất cứ thứ gì, sử dụng metaprogramming quá nhiều có thể làm cho code khó maintain. Sự sắp xếp code có thể tuyệt với cho job security, nhưng có thể hiệu năng thấp, khó đọc, và khó maintain bởi những người khác.
Khi được sử dụng chính xác và trong trường hợp thích hợp, metaprogramming có thể là tuyệt vời. Các trick được biết khi sử dụng nó và khi ngăn cản nó. Đôi lúc chỉ cần sử dụng mỗi Hash
đã là một giải pháp tốt nhất.
All rights reserved