6 chức năng dễ gây nhầm lẫn của Ruby

Ruby là một ngôn ngữ ngắn gọn, tiện dụng, dễ dùng, dễ nghiện. Để có được sự tiện dụng này, những người thiết kế ngôn ngữ đã phải hy sinh một số chi tiết nhỏ, khiến cho đôi khi lập trình viên trở nên nhầm lẫn.

Dưới đây là 6 tiện ích nhỏ của Ruby mà lập trình viên nên cẩn thận mỗi khi sử dụng.

1. Hàm []

Một trong những hàm tiện dụng nhất và được yêu thích nhất của Ruby.

Giống như trong các ngôn ngữ khác, hàm [] được dùng để truy cập đến phần tử của Array:

array = [1, 2, 3]
array[0] # => 1

hoặc một địa chỉ của Hash:

hash = {foo: "bar"}
hash[:foo] # => "bar"

Với một String, ta có thể truy cập đến kí tự bên trong:

"Hello World"[0] # => "H"

Điều rắc rối ở đây là, ta có thể sử dụng [] để gọi đến attribute của một instance:

book = Book.first
book["total_pages"] # => 100

hoặc sử dụng để gọi lamda:

hello = (-> (name) { "Hi, #{name}!" })
hello["John"] # => "Hi, John"

Chuyện gì sẽ sảy ra nếu ta liên kết các chức năng này lại với nhau?

wise_words_factory = (-> (number_of_elements) { (1..number_of_elements).map { WideWord.random } })

wise_words_factory[10][0][:category] # "Body builder"
wise_words_factory[10][0][:words] # "No pain, no gain"
wise_words_factory[10][0][:words][0] # "N"

Chỉ một dòng code trông có vẻ bình thường, nhưng đã dùng đến 3 kỹ thuật khác nhau chỉ với hàm [], và là cơn ác mộng cho việc debugging.

Bonus:[] là một hàm nên ta hoàn toàn có thể sử dụng hàm try hoặc send để gián tiếp gọi đến:

array = [1, 2, 3, 4]
array.send :[], 0 # => 1
array.try :[], 1     # => 2

2. Toán tử %

Cũng giống như hàm [], toán tử % trong Ruby cũng được tận dụng tối đa:

Được sử dụng để tính module số nguyên như các ngôn ngữ khác:

103 % 100 # => 3

Ngoài ra, toán tử % còn được sử dụng để tạo format cho string, dẫn đến những nhầm lẫn "khó đỡ":

"%s: %d" % ["age", 18] # => age: 18

Để tránh nhầm lẫn, ta có thể sử dụng module Kernel.format cũng cho cũng kết quả.

Kernel.format(format, "age", 18) # => age: 18

3. Hàm Integer#zero?

Với những ai không biết về hàm zero?, thì đâu là một giải thích đơn giản:

assert_equal(1 == 0, 1.zero?) # => true

Nhìn qua thì hàm zero? trông rất đẹp và gọn, thậm chí còn được khuyến khích sử dụng bởi những gem check code convention như Rubocop.

Nhưng hàm này có thể gây ra nhiều rắc rối hơn là giải quyết, bởi vì về bản chất hai biểu thức trên không hoàn toàn bằng nhau. Sử dụng toán tử == 0 sẽ thực hiện so sánh bằng với một hằng số, trong khi hàm zero?, trong lý thuyết hướng đối tượng, sẽ gửi một lời gọi hàm đến đối tượng, và trả về kết quả nếu có method được định nghĩa.

arr = [1, 2, 3, 4]
arr == 0 # => false
arr.zero? # => NoMethodError

Vậy nên hãy sử dụng == 0, hoặc là chắc chắn bạn sử dụng zero? với kiểu dữ liệu số.

4. Biến toàn cục $[number]

Hãy cùng theo dõi hành động regex matching dưới đây:

# test.rb
string = "Hi, John!"
matched = %r(^(.+), (.+)!$).match(string)
matched[0] => "Hi, John!"
matched[1] => "Hi"
matched[2] => "John"

Không có gì đặc biệt. Nhưng Ruby còn hỗ trợ một cách khác để lấy dữ liệu như sau:

string = "Hi, John!"
%r(^(.+), (.+)!$).match(string)
$1 => "Hi"
$2 => "John"

Đến đây bạn có thể đập bàn và hét lên: "Thay đổi biến toàn cục cho mỗi thao tác regex matching có thể sinh ra cả đống bug tiềm tàng!". Không sao, team phát triển Ruby cũng đã nghĩ đến điều này. Theo như documentation, các biến toàn cục này là cục bộ theo các luồng và các hàm. Về cơ bản các biến toàn cục này không phải là các biến toàn cục 😐

Nghịch thêm một chút nữa, tôi thử với matched[0]:

$0 # => "rails_console"

Vậy là biến $0 trong Ruby dùng để lưu trữ thông tin môi trường hiện tại.

Thử với index âm:

matched[-1] # => "John"
$-1 # => nil

Thậm chí ta có thể gán giá trị cho biến $-1:

$-1 = 100
$-1 # => 100

Thử đi sâu hơn nữa:

$-9 # => nil
$-10 # => SyntaxError

Kết luận: Các biến toàn cục $-[number] chỉ hoạt động khi number chạy từ 1 đến 9.

5. Hàm toàn năng Time.parse

Time.parse là một hàm parse thời gian rất mạnh, hỗ trợ rất nhiều format:

Time.parse("Thu Nov 29 14:33:20 GMT 2001")
# => 2001-11-29 14:33:20 +0000

Time.parse("2011-10-05T22:26:12-04:00")
# => 2011-10-05 22:26:12 -0400

Đôi khi mạnh quá mức cần thiết 😱

Time.parse("Thu Nov 29 a:b:c GMT 2001")
# 2017-11-29 00:00:00 +0100

Để hiểu được ta cần nhìn vào documentation của Time.parse. Thực ra hàm này có một tham số phụ thứ hai, một mốc thời gian để Ruby căn cứ vào mỗi khi một phần của string không thể parse được, lấy giá trị mặc định là Time.now.

Đây là một con dao hai lưỡi. Ví dụ như đoạn code trên, string nhập vào là hoàn toàn sai, và ta nên nhận về một exception hơn là một mốc thời gian sai.

Ngoài ra việc hỗ trợ quá nhiều format cũng gây ra rắc rối:

Time.parse("12/27") # => 2017-12-27 00:00:00 +0100
Time.parse("27/12/2017") # => 2017-12-27 00:00:00 +0100

6. Delegator

Cho ví dụ dưới đây:

require "delegate"

class Foo < Delegator
  def initialize(the_obj)
    @the_obj = the_obj
  end

  def __getobj__
    @the_obj
  end
end

foo = Foo.new(1)
foo.inspect # => 1
foo == 1 # => true

Theo ý kiến của tôi, biểu hiện như trên là sai và gây ra nhầm lẫn tai hại vì một delegator của 1 không thể bằng một hằng số 1.

Equality — At the Object level, == returns true only if obj and other are the same object. - Ruby documentation.

Theo như định nghĩa của Ruby documentation, hai object trên không thể bằng nhau.

Lý do biểu thức trả về là bằng nhau vì với mỗi lời gọi nhận được, delegator chỉ cần truyền nó cho object gốc, không quan tâm đó là hello hay ==.

Điều này dẫn tới một vấn đề khác:

foo = Foo.new(nil)
foo.inspect # => nil

Khi ta muốn dump một object foo, Ruby sẽ dump object được delegate chứ không phải là bản thân delegator. Theo lý tưởng thì nó sẽ phải chạy kiểu như:

foo = Foo.new(nil)
foo.inspect # <Foo: delegated: nil>

Trên đây là một số tính năng cần phải lưu ý khi làm việc với Ruby. Hy vọng bài viết này có ích với bạn!