Toán tử đơn nguyên của Ruby và cách định nghĩa lại chúng

Bài viết này được dịch từ Ruby’s Unary Operators and How to Redefine Their Functionality của tác giả Peter Cooper.

Trong toán học, một phép toán đơn nguyên là một phép toán với chỉ một số hạng. Trong Ruby, một toán tử đơn nguyên là một toán tử mà nó chỉ nhận duy nhất một tham số. Ví dụ toán tử - trong -5 hay ! trong !true.

Ngược lại, một phép toán nhị nguyên, ví dụ như 2 + 3, thực hiện với hai tham số. Ở đây là 2 và 3 (với một số là bên nhận lời gọi và một số là tham số trong lời gọi hàm +).

Ruby chỉ có một số ít các toán tử đơn nguyên, và việc định nghĩa lại các toán tử nhị nguyên như + hay [] giúp khiến cho đối tượng có thêm một số tính năng tiện dụng, trong khi các toán tử đơn nguyên lại ít khi được định nghĩa lại hơn. Theo kinh nghiệm của tôi, rất nhiều dân Ruby không biết rằng các toán tử đơn nguyên có thể được định nghĩa lại và ... một cách kỹ thuật thì bạn không thể "định nghĩa lại một toán tử" nhưng thực chất các toán tử của Ruby là các phương thức, và bạn biết rằng ... việc định nghĩa lại một phương thức trong Ruby rất dễ dàng.

Một ví dụ với [email protected]

Hãy khởi động với toán tử đơn nguyên -. Toán tử đơn nguyên - với toán tử nhị nguyên - không phải là một (do toán tử nhị nguyên có hai toán hạng). Mặc định, toán tử đơn nguyên - được sử dụng như là một ký pháp dành cho các số âm, ví dụ như -25, trong khi toán tử nhị nguyên thực hiện phép trừ, như trong phép toán 50 - 25. Dù chúng trông khá giống nhau, nhưng chúng mang khái niệm khác nhau, phép toán khác nhau, và thực hiện những phương thức khác nhau trong Ruby.

Sử dụng toán tử đơn nguyên - trên một xâu trong irb:

> -"this is a test"
NoMethodError: undefined method `[email protected]' for "this is a test":String

Lớp String không định nghĩa toán tử đơn nguyên - nhưng irb cho chúng ta một gợi ý. Do sự xung đột giữa các phiên bản đơn nguyên và nhị nguyên của - mà phiên bản đơn nguyên có chứa hậu tố @. Sự trợ giúp này đưa chúng ta đến một giải pháp:

str = "This is my STRING!"

def str.-@
  downcase
end

p str    # => "This is my STRING!"
p -str   # => "this is my string!"

Chúng ta vừa định nghĩa toán tử đơn nguyên - bằng cách định nghĩa phương thức [email protected] tương ứng của nó để biến đổi đối tượng nhận được phương thức này về dạng chữ thường.

Một vài toán tử khác: [email protected], ~, ! (và not)

Hãy thử một ví dụ lớn hơn trong đó chúng ta kế thừa lớp String và thêm các phiên bản của một vài toán tử đơn nguyên dễ được định nghĩa:

class MagicString < String
  def +@
    upcase
  end

  def -@
    downcase
  end

  def !
    swapcase
  end

  def ~
    # Thực hiện một phép biến đổi ROT13 - http://en.wikipedia.org/wiki/ROT13
    tr 'A-Za-z', 'N-ZA-Mn-za-m'
  end
end

str = MagicString.new("This is my string!")
p +str        # => "THIS IS MY STRING!"
p !str        # => "tHIS IS MY STRING!"
p (not str)   # => "tHIS IS MY STRING!"
p ~str        # => "Guvf vf zl fgevat!"
p +~str       # => "GUVF VF ZL FGEVAT!"
p !(~str)     # => "gUVF VF ZL FGEVAT!"

Lần này chúng ta không chỉ định nghĩa lại -/[email protected] mà cả toán tử đơn nguyên + (sử dụng phương thức [email protected]), !not (sử dụng phương thức !), và ~.

Tôi sẽ không giải thích tường tận ví dụ trên vì nó khá là đơn giản và mang tính minh họa hơn hàng đống chữ. Chỉ cần chú ý những phép toán nào mà mỗi toán tử đơn nguyên thực hiện và xem sự thực hiện đó liên quan thế nào đến thứ chúng gọi tới và kết qủa trả về.

Các trường hợp đặc biệt: & và *

&* cũng là các toán tử đơn nguyên trong Ruby, nhưng chúng là những trường hợp đặc biệt, liên hệ với "những cú pháp ma thuật bí ẩn". Chúng thực hiện những gì?

& và to_proc

Bài viết The unary ampersand in Ruby của Reg Braithwaite đưa ra một cách giải thích tuyệt vời về &, nhưng nói một cách ngắn gọn thì & có thể biến các đối tượng thành procs/blocks bằng cách gọi phương thức to_proc của đối tượng đó. Ví dụ:

p ['hello', 'world'].map(&:reverse)  # => ["olleh", "dlrow"]

Enumerable#map thường nhận một block thay vì một tham số, nhưng & gọi Symbol#to_proc và sinh ra một đối tượng proc đặc biệt cho phương thức reverse. Proc này trở thành block cho map và bằng cách đó đảo ngược các xâu trong mảng.

Do vậy bạn có thể "ghi đè" toán tử đơn nguyên & (đừng nhầm lẫn với toán tử nhị nguyên tương ứng!) bằng cách định nghĩa to_proc của một đối tượng, với yêu cầu duy nhất là bạn phải trả về một đối tượng Proc. Bạn sẽ thấy một ví dụ cho việc này ở dưới.

* và splatting

rất nhiều cách để splat nhưng ngắn gọn thì, * có thể coi là một toán tử đơn nguyên làm "nổ" một mảng hoặc một đối tượng có cài đặt to_a và trả về một mảng.

Để ghi đè toán tử đơn nguyên * (không phải toán tử nhị nguyên * như trong 20 * 32) thì bạn có thể định nghĩa một phương thức to_a và trả về một mảng. Tuy nhiên mảng bạn trả về sẽ phải đối mặt với những kết qủa từ hành vi điển hình của *.

Một ví dụ đầy đủ

Chúng ta đã đi đến cuối của hành trình xem xét các toán tử đơn nguyên của Ruby, nên tôi muốn đưa ra một ví dụ về cách ghi đè (hay ghi đè một phần) mà nên trở thành tài liệu của chúng:

class MagicString < String
  def +@
    upcase
  end

  def -@
    downcase
  end

  def ~
    # Thực hiện một phép biến đổi ROT13 - http://en.wikipedia.org/wiki/ROT13
    tr 'A-Za-z', 'N-ZA-Mn-za-m'
  end

  def to_proc
    Proc.new { self }
  end

  def to_a
    [self.reverse]
  end

  def !
    swapcase
  end
end

str = MagicString.new("This is my string!")
p +str                # => "THIS IS MY STRING"
p ~str                # => "Guvf vf zl fgevat!"
p +~str               # => "GUVF VF ZL FGEVAT!"
p %w{a b}.map &str    # => ["This is my string!", "This is my string"]
p *str                # => "!gnirts ym si sihT"

p !str                # => "tHIS IS MY STRING"
p (not str)           # => "tHIS IS MY STRING"
p !(~str)             # => "gUVF VF ZL FGEVAT"

Đó gần như là một cẩm nang về các toán tử đơn nguyên 😃

Một ví dụ nữa: TestRocket

TestRocket là một thư viện kiểm thử nhỏ tôi xây dựng cho vui một vài năm trước. Nó chủ yếu dựa vào các toán tử đơn nguyên. Ví dụ, bạn có thể viết ca kiểm thử như sau:

+-> { Die.new(2) }
--> { raise }
+-> { 2 + 2 == 4 }

# Hai ca kiểm thử sau sẽ thất bại
+-> { raise }
--> { true }

# Một ca kiểm thử "chưa thực hiện"
~-> { "this is a pending test" }

# Một mô tả
~-> { "use this for descriptive output and to separate your test parts" }

Đoạn -> { } chỉ là kiểu "stabby lambdas" của Ruby 1.9+ nhưng, với sự trợ giúp từ Christoph Grabo, tôi đã thêm các toán tử đơn nguyên vào chúng nên bạn có thể thêm các tiền tố +, -, ~ hoặc ! để có được những hành vi khác nhau.

Hi vọng bạn có thể thực hiện nhiều ứng dụng hữu ích với các phương thức đơn nguyên trong các đối tượng của bạn 😉