蒼時弦也
蒼時弦也
資深軟體工程師
發表於

Ruby 中該如何 Raise 一個錯誤

前幾天的晚上朋友在 Facebook 上問了一個問題。

1raise HTTPError, 'Not Found'

1raise HTTPError.new('Not Found')

哪個比較快?也因為這樣,我們意外的發現 Ruby 對上面兩段程式碼的定義上其實是不太一樣的。

在 Ruby 中 raise 一般情況下有以下幾種運作方式。

預設情況

1# Case 1
2raise # => #<RuntimeError>
3
4# Case 2
5raise 'NotFound' # => #<RuntimeError: "Not Found">

一般用法

1# Case 3
2raise HTTPError, 'Not Found'
3
4# Case 4
5raise HTTPError.new('Not Found')

不過,上面這兩段程式碼的差異在哪裡呢?從 Ruby 的原始碼可以看到 .new 的行為會多做一次檢查。

 1static VALUE
 2make_exception(int argc, const VALUE *argv, int isstr)
 3{
 4    VALUE mesg, exc;
 5    int n;
 6
 7    mesg = Qnil;
 8    switch (argc) {
 9      case 0:
10        break;
11      case 1:
12        exc = argv[0];
13        if (NIL_P(exc))
14            break;
15        // 檢查了是否為字串
16        if (isstr) {
17            mesg = rb_check_string_type(exc);
18            if (!NIL_P(mesg)) {
19                mesg = rb_exc_new3(rb_eRuntimeError, mesg);
20                break;
21            }
22        }
23        n = 0;
24        // 繼續跟 raise HTTPError, 'Not Fonud' 一樣的行為
25        goto exception_call;
26
27      case 2:
28      case 3:
29        exc = argv[0];
30        n = 1;
31      exception_call:
32        mesg = rb_check_funcall(exc, idException, n, argv+1);
33        if (mesg == Qundef) {
34            rb_raise(rb_eTypeError, "exception class/object expected");
35        }
36        break;
37      default:
38        rb_check_arity(argc, 0, 3);
39        break;
40    }
41    if (argc > 0) {
42        if (!rb_obj_is_kind_of(mesg, rb_eException))
43            rb_raise(rb_eTypeError, "exception object expected");
44        if (argc > 2)
45            set_backtrace(mesg, argv[2]);
46    }
47
48    return mesg;
49}

從這個角度看,我們會發現差異其實只是「多一次檢查」的程度,甚至不太影響運行的效能。不過從 Programming Ruby 這本書中的範例,卻發現了一個稍微意想不到的使用方法。

 1def readData(socket)
 2  data = socket.read(512)
 3  if data.nil?
 4    raise RetryException.new(true), "transient read error"
 5  end
 6  # .. normal processing
 7end
 8
 9# ...
10
11begin
12  stuff = readData(socket)
13  # .. process stuff
14rescue RetryException => detail
15  retry if detail.okToRetry
16  raise
17end

仔細一看,明明應該是 @message 的數值,被放入了非字串的數值,而且這個錯誤還提供了 #okToRetry 這樣的方法讓我們可以獲取到這個數值。

回到剛剛 Ruby 中 make_exception 的原始碼,在 2 ~ 3 個參數的情況下,回傳的 mesg 變數是透過呼叫一個透過 idException 指標定義的方法來產生的,而傳入的參數剛好是 raise 的第二個參數。

 1     case 2:
 2      case 3:
 3        exc = argv[0];
 4        n = 1;
 5      exception_call:
 6        mesg = rb_check_funcall(exc, idException, n, argv+1); // 呼叫 idException 指標對應的某個方法
 7        if (mesg == Qundef) {
 8            rb_raise(rb_eTypeError, "exception class/object expected");
 9        }
10        break;

idException 對應的其實是呼叫物件上的 #exception 方法,是所有 Error 類型物件必須存在的方法。

也就是說,實際上我們可以讓我們的 Error 附帶一些額外資訊,在一些情況下處理錯誤的時候可以用來輔助我們。

不過,既然第一個參數已經被我們自訂的錯誤資訊替換了,那麼 Ruby 是怎麼設定錯誤訊息的?

實際上,在 idException 對應的 #exception 方法上,會複製現有的物件,件並且把錯誤訊息放進去。

#exception 的原始碼是這樣實作的。

 1static VALUE
 2exc_exception(int argc, VALUE *argv, VALUE self)
 3{
 4    VALUE exc;
 5
 6    if (argc == 0) return self;
 7    if (argc == 1 && self == argv[0]) return self;
 8    exc = rb_obj_clone(self);
 9    exc_initialize(argc, argv, exc);
10
11    return exc;
12}
 1static VALUE
 2exc_initialize(int argc, VALUE *argv, VALUE exc)
 3{
 4    VALUE arg;
 5
 6    rb_scan_args(argc, argv, "01", &arg);
 7    rb_ivar_set(exc, id_mesg, arg);
 8    rb_ivar_set(exc, id_bt, Qnil);
 9
10    return exc;
11}

也就是說,我們實際上 raise 出來的例外,其實是被重新修改過的,不過這也讓我們在使用 Ruby 的錯誤上可以更加的彈性。

這也是為什麼像是 Rubocop 之類的軟體,會建議使用下面這種方式產生錯誤的原因。

1raise NotFoundError
2
3raise NotFoundError, 'Current page is unavailable'

關於 #exception 的部分感謝五倍的同事在討論的時候提出來,才發現還有後續的處理將 #message 設定上去。