1 + nil などがエラーでなく nil を返すようにしたかったが、 refinements で NilClass#coerce を生やす方法はうまくいかなかった。たぶん refinements の有効範囲的に仕方ない?
試したコード
Ruby の数値クラスの二項演算は、レシーバー(演算子の左側)の知らないクラスが相手(演算子の右側、第1引数)である場合、相手の #coerce を呼び出してクラスを変換してもらう仕組みになっている。それにより、相手のクラスに二項演算を任せることができる。
というわけで、二項演算で右側を nil にしたいなら、数値クラスは触らず nil のほうに変換と二項演算を定義すればいい。ひとまず + と - (とついでに単項演算)に対応させてみた。
module NullableOperators
refine NilClass do
def coerce(other)
[nil, self] # 注: <=> については考慮していない
end
%i[+@ -@ + -].each do |name|
define_method(name) { |*| nil }
end
end
end
しかし使ってみると、レシーバーが nil のとき( nil + 1 など)はうまくいくが、逆のときはエラーになってしまった。
using NullableOperators
nil + 1 #=> nil
-nil #=> nil
nil.coerce(1) #=> [nil, nil]
1 + nil # nil can't be coerced into Integer (TypeError)
原因
公式ドキュメントの refinements の説明を見てみると、スコープについて書いてあった。
Refinements are lexical in scope. Refinements are only active within a scope after the call to
using. Any code before theusingstatement will not have the refinement activated.When control is transferred outside the scope, the refinement is deactivated. This means that if you require or load a file or call a method that is defined outside the current scope the refinement will be deactivated:
(コード例)
using の有効な範囲はコードの構造で決まり(静的スコープ)、他のファイル内のコードや using より前に定義されたメソッド内では、たとえ using 実行後に呼び出したとしても refinements が無効となる。組み込みの数値クラスの二項演算は明らかに別の場所で定義されているので、 refinements の効果が及ばないということになる。
代案
refinements を使う限りは、 #coerce の仕組みを使わずやるしかなさそう。
数値クラスも refine する
#coerce を呼ばないよう、使う予定の数値クラスの二項演算を改造する。メソッド内で super を呼べば、 refine 前のメソッドを呼び出せる1。
module NullableOperators
unary = %i[+@ -@]
binary = %i[+ -]
refine NilClass do
unary.each do |name|
define_method(name) { nil }
end
binary.each do |name|
define_method(name) { |_| nil }
end
end
[Integer, Rational, Float, Complex].each do |klass|
refine klass do
binary.each do |name|
define_method(name) { |other| other.nil? ? nil : super(other) }
end
end
end
end
using NullableOperators
nil + 1 #=> nil
-nil #=> nil
1 + nil #=> nil
1 + 2.0 #=> 3.0
NaN で済ませる
今回の場合はただ「他の数値と演算しても変化しない」ものが欲しかったので、 nil にこだわらなければ浮動小数点数の特別な値 NaN ( Float::NAN など)が使える。元々ある仕組みなので refinements も何も必要ない。
NaN には種類があるとか、 NaN 同士の等値性判定が偽になるとか、 nil とは異なる面もあるので細かいところは注意がいる。
nan = Float::NAN
nan + 1 #=> NaN
-nan #=> NaN ※符号はマイナス
nan.coerce(1) #=> [1.0, NaN]
1 + nan #=> NaN
-
refine の中でモジュールを include している場合は、モジュールのメソッドが優先される。 ↩