エンジニアの澤田です。
この連載では、社内のRuby (on Rails)コードで気になった箇所の問題点やそこから発展して関連事項を議論しています。
前回投稿の マネーフォワード社内PRに見られるRubyの書き方について (3) では文字列の生成と検証を考察しました。 記事に言及してもらったり、コメントをもらったりして、励みになっています。
今回は真理値について考察します。
【バックナンバー】
- マネーフォワード社内PRに見られるRubyの書き方について - (1) 配列の生成
- マネーフォワード社内PRに見られるRubyの書き方について - (2) ハッシュの生成
- マネーフォワード社内PRに見られるRubyの書き方について - (3) 文字列の生成や検証
題材とするコードは、社内のGitHubプルリクエストで実際に見かけたコードから問題点に関係する部分を抽出し、抽象化したもので、見かけたものそのままではありません。
今回は真理値の周辺を考察します。プログラミングにおいて真理値が何のためにあるかといえば、時に論理演算を施され、最終的に制御構造の条件部やメソッドに渡される引数やメソッド内のフラグなどとなって、場合分けに使われることがほとんどだと思います。従って、今回の真理値の考察には場合分けについての議論が多く登場します。
Rubyの論理演算で「偽」とされる値はfalse
とnil
だけです。しかし、空文字列や、空配列、零などといったオブジェクトを実質的に偽のようにして場合分けしているコードも見かけます。このような場合分けの中には必要なものも必要でないものもあります。また反対に、オブジェクトの値false
やnil
を実質的に「真」と見なし、メタな評価基準を導入して偽を判定する必要のある場面もあります。このような、ユースケースごとの実質的な真理値のずれもまた真理値を考察するにあたっての大きな話題です。
本稿では、次の「真理値の定義」と「述語メソッド」の節で概念的なことを整理して、その後の節で問題のあるケースを見ていきます。
真理値の定義
本稿では、「真理値」という言葉を、Rubyオブジェクトの値を直接に指すものでなく、そのオブジェクトの論理演算や制御構造での振る舞いを表すものとして用い、「偽」か「真」の値を取るものとします。以下で、Rubyと他のプログラミング言語における真理値の定義を考察します。
Rubyの真理値
Rubyの論理演算ではfalse
とnil
だけが偽の真理値で、他は真です。これには次のような理由があると考えられます。
Rubyの文字列や、配列、ハッシュなどはミュータブルであり、同一のインスタンスが空になったり空でなくなったり変化することがあります。
empty_string = "" empty_string.object_id # => 70325530487340 empty_string.empty? # => true empty_string.replace("foo") empty_string.object_id # => 70325530487340 empty_string.empty? # => false
そこで、もしRubyで空文字列や、空配列、空ハッシュを偽と定めるとすると、1つのインスタンスの真理値がプログラムの実行途中で変わり得ることになります。Ruby (Matz' Ruby Implementation)のソースコードを見る限りでは、真理値の判定はインスタンスの同一性に基づいて行われています。頻繁に行われる論理演算はインスタンスの状態を考慮しないで行うという暗黙の原則があることが推測され、そのため空文字列や、空配列、空ハッシュはRubyで偽とし得なかったと考えられます。そうすると、Rubyで偽と定められ得るオブジェクトはイミュータブルなものに限られます。
イミュータブルなInteger
, Float
などの数値関連のいくつかのクラスの中にそれぞれある零インスタンス(0
, 0.0
など)については、偽として扱いたい動機があまりなかったようです。零を偽とすることが検討され、結局その案が取り入れられなかったことが過去にあったそうで、その経緯がRuby開発者の方によってまとめられています。1
他のプログラミング言語での偽を広く定める真理値
プログラミング言語によって、空文字列や、空配列、零などを偽と定めることがあります。
これらのオブジェクトを特徴付ける性質を挙げるとすれば、「加算」と呼べるある演算に関して単位元になっていることです。Rubyではこれらを偽としませんが、Rubyでもこれらのオブジェクトは然るべき加算メソッドの単位元です。
"" + "foo" # => "foo" "foo" + "" # => "foo" [] + ["foo", "bar"] # => ["foo", "bar"] ["foo", "bar"] + [] # => ["foo", "bar"] {}.merge({a: "foo", b: "bar"}) # => {:a=>"foo", :b=>"bar"} {a: "foo", b: "bar"}.merge({}) # => {:a=>"foo", :b=>"bar"} 0 + 3 # => 3 3 + 0 # => 3
しかし、単位元であることがどうして偽とすることにつながるのか直ちに明確ではありません。面白いことに、複数のオブジェクトを偽と定めた場合、それらのオブジェクトは論理和に関して、左単位元であるものの、(右)単位元でなくなります。実際にRubyでは、false
やnil
は||
の左単位元ですが、以下の結果から分かるように、(右)単位元でありません。
nil || false # => false false || nil # => nil
空オブジェクトや零と偽として扱う理由が明確でない一方で、逆に偽であるnil
を空オブジェクトや零として扱うためのメソッドがあります。
nil.to_a # => [] nil.to_h # => {} nil.to_s # => "" nil.to_i # => 0 nil.to_r # => (0/1) nil.to_f # => 0.0 nil.to_c # => (0+0i)
これらのメソッドのユースケースを見ると、空オブジェクトや零で表されるべき値を特に理由もなくnil
として保持していて、後で空オブジェクトや零にするという用法が多いような気がします。その一例は後の節で出てきます。
述語メソッド
レシーバーによって論理演算の偽と真のどちらの値も返すメソッドで、真の値が比較的簡単なものを述語メソッドと呼び、メソッド名の末尾に?
を付ける規約があります。この節では、述語メソッドについての理解を深めるために、いくつかの観点から分類してみます。
返り値の種類
述語メソッドは返り値の組み合わせによって3種類あります。
偽の返り値 | 真の返り値 | |
---|---|---|
狭い意味での述語メソッド | false |
true |
広い意味での述語メソッド | nil |
簡単なオブジェクト |
String#casecmp? |
nil , false |
true |
述語メソッドの大半はここで「狭い意味での述語メソッド」と呼んだものに当たり、例はString#match?
やNumeric#zero?
です。同じパターンの返り値を取るもので、?
で終わる形をとっていないメソッドとしてはString#==
などがあります。
「広い意味での述語メソッド」と呼んだものについては、知名度が低かったり、述語メソッドとして認識していない人もいますが、標準Rubyにもあります。例えば、File#size?
やNumeric#nonzero?
などがそうです。また、返り値が同じパターンで、?
で終わる形をとっていないメソッドとしてはString#=~
などがあります。
広い意味での述語メソッドは真の値(簡単なオブジェクト)を複数持ちます。つまり、通常の値同士の対比は「
簡単なオブジェクト」の値の範囲内で行われ、例外的な場合を表すメタな値としてnil
があると考えると良いでしょう。
否定の有無
述語メソッドの中には、レシーバーが偽やそれに準ずるもの(空オブジェクトなど)であるときに真を返すものがあります。
Kernel#nil?, NilClass#nil? ENV.empty?, Array#empty?, SizedQueue#empty?, Queue#empty?, Hash#empty?, String#empty?, Symbol#empty?
これらのメソッドは真理値を反転するため、注意が必要です。特に&.
との併用は大抵間違っています。例えば、nil
と空オブジェクトと空でないオブジェクトの3つの場合を取り得る変数errors
を次のように前者2つ対3つ目で場合分けしようとしているコードを見かけました。
errors |
想定した条件の返り値 | 想定した真理値 |
---|---|---|
nil |
nil |
偽 |
空オブジェクト | false |
偽 |
空でないオブジェクト | true |
真 |
[見かけた書き方]
if errors&.empty?
しかし、想定した結果を出すことに失敗しています。実際には次のような結果が返ります。
errors |
error&.empty? |
真理値 |
---|---|---|
nil |
nil |
偽 |
空オブジェクト | true |
真 |
空でないオブジェクト | false |
偽 |
empty?
が否定を持つために、偽とされるべき空オブジェクトに対して真を返し、&.empty?
をそのまま通過するnil
と逆側の真理値になってしまったためです。
errors
のnil
でない場合の値が文字列、配列、ハッシュの場合は、それぞれto_s
, to_a
, to_h
を使うことで、初めに想定した結果とは真理値が逆になりますが、想定通りの場合分け(グルーピング)をすることが出来ます。さらに、肯定のif
の代わりに否定のunless
を使えば、想定通りの制御構造になります。
unless errors.to_s.empty?
unless errors.to_a.empty?
unless errors.to_h.empty?
errors |
errors.to_s.empty? , errors.to_a.empty? , errors.to_h.empty? |
真理値 |
---|---|---|
nil |
true |
真 |
空オブジェクト | true |
真 |
空でないオブジェクト | false |
偽 |
あるいは、ハッシュの場合に限っては、否定を含まないHash#any?
を使って真理値を反転せずに空かどうかを調べることが出来ます。その場合には、真理値が反転しないので、&.
を使うことによって、nil
値の場合も含めて1つ目の表で想定した通りの真理値が得られます。
nil&.any? # => nil {}&.any? # => false {foo: :bar}&.any? # => true
配列の場合にArray#any?
を使うと、同様に真理値は反転しませんが、「空配列対空でない配列」ではなく「真オブジェクトを含まない配列対含む配列」の区別になり、違ったものになってしまうので、注意して下さい。
nil&.any? # => nil []&.any? #=> false [nil]&.any? # => false [false]&.any? # => false [""]&.any? # => true [:foo]&.any? # => true
評価レベル
レシーバーをどのレベルで評価するかによって、述語メソッドは3つに分類できます。本稿では評価のレベルを次のように呼ぶことにします。
- クラス判定
- インスタンス判定
- 状態判定
本稿で「クラス判定」をする述語メソッドとは、1つのインスタンスに対して返り値が常に一定で、さらにその返り値がクラスの全インスタンスに対して同じものを言います。つまり、これはクラスへの所属を調べるもので、次のものがあります。
Kernel#instance_of? Kernel#kind_of? Kernel#nil?, NilClass#nil? Numeric#integer?, Integer#integer? Numeric#real?, Complex#real?
ここでややこしいのがinteger?
, real?
です。これらはメソッド名から想像されるインスタンスの数学的な同値関係に関係がなく、所属クラスを判定します。例えば、数学的には0
は0.0
とも0i
とも同じで、これは$\mathbb{Z}$, $\mathbb{R}$のどちらにも属しますが、これらをRubyリテラルとして解釈した場合、同値関係==
を満たすのに、それぞれInteger
, Float
, Complex
クラスのインスタンスであり、integer?
やreal?
に対して異なる値を返します。
0 == 0.0 # => true 0 == 0i # => true 0.class # => Integer 0.0.class # => Float 0i.class # => Complex 0.real? # => true 0.0.real? # => true 0i.real? # => false 0.integer? # => true 0.0.integer? # => false 0i.integer? # => false
しかも、real?
は「Complex
クラスのインスタンスでない」という所属関係の否定を表すところがややこしさを増しています。これは、RubyのクラスInteger
, Rational
, Float
, Complex
の間に包含関係(継承関係)がないにも関わらず、それらに対応する(Rubyが近似しようとしている)数学の集合 $\mathbb{Z}$, $\mathbb{Q}$, $\mathbb{R}$, $\mathbb{C}$ の間に $\mathbb{Z} \subset \mathbb{Q} \subset \mathbb{R} \subset \mathbb{C}$ という包含関係があり、「$\mathbb{C}$ への所属」という言い方ではComplex
対Integer
, Rational
, Float
の区別が出来ず、「$\mathbb{R}$ への所属」という言い方になってしまったからだということが推察されます。
本稿で「インスタンス判定」をする述語メソッドとは、1つのインスタンスについて返り値が決して変わらず、その返り値がインスタンスによって異なるものを言います。次のものがあります。
Numeric#zero?, Float#zero? Numeric#nonzero? Numeric#finite?, Float#finite?, Complex#finite? Numeric#infinite?, Float#infinite?, Complex#infinite? Numeric#negative?, Rational#negative?, Float#negative? Numeric#positive?, Rational#positive?, Float#positive? Float#nan? Integer#even? Integer#odd?
これらのレシーバーは、全てイミュータブルなオブジェクトです。
本稿では「状態判定」をする述語メソッドは、1つのインスタンスについて、その状態によって返り値が変わるものを指しています。この種類の述語メソッドが最も多く、前の節で考察したString#empty?
が1つの例です。
不要な論理や表現
空オブジェクトに対するガード
必要がないのに空オブジェクトを条件分岐したり空オブジェクトからガードしているコードを何度か見ました。「ガード」というのは、それに続くコードで問題を起こすと考えられる場合をコードの本流から隔離する制御構造をいいます。例えば、array
が空配列のときにメソッドの途中で脱出する次のようなコードを見ました。
[見かけた書き方]
return if array.empty? array.each{...}
これにより、array
が空の場合に、次の行のeach
が適用されなくなります。次のような場合分けも同様の働きをする構造です。
[見かけた書き方]
unless array.empty? array.each{...} end
unless array.empty? array.select!{...} end
空配列も配列なので、空でない配列に対して呼び出されるインスタンスメソッドは空配列に対しても呼び出されます。空だからということでメソッド未定義エラーは発生しません。また、空配列には要素はないので、イテレーションしても、ブロックは実行されません。上記のようなガードや制御は冗長で、不必要にコードを複雑にしています。次のコードで足ります。
array.each{...}
array.select!{...}
ちょっとした変種として、次のようなものもありました。
[見かけた書き方]
foo = if array.empty? nil else array.find{...} end
これも過剰な場合分けです。偽に準じる空オブジェクトは分けておかなければならないという意識が働いてしまうのでしょうか。空配列からは何を探そうとしても結果はnil
になる:
[].find{false} # => nil [].find{true} # => nil
ので、次のコードで十分です。
foo = array.find{...}
文字列でこれに対応する場合が次の例です。
[見かけた書き方]
foo = if string.empty? nil else string[regex] end
regex
が空正規表現//
でない限り、空文字列に対する[regex]
は、マッチの失敗によりnil
を返す:
""[//] # => "" ""[/./] # => nil
ので、regex
が空正規表現でない限り、上の式は
foo = string[regex]
と等価です。
同じ論理の繰り返し
強く主張を持って次のように書いているコードを見かけたことがあります。
[見かけた書き方]
%i[a b c].include?(:a) ? true : false
!!%i[a b c].include?(:a)
二重否定!!
は偽オブジェクト対真オブジェクトをfalse
対true
に写像するためによく使われます。1つ目の!
で偽オブジェクトをtrue
に、真オブジェクトをfalse
に写し、2つ目の!
でfalse
とtrue
を入れ替えます。
この例で使われているArray#include?
のようなメソッドは、そもそもfalse
かtrue
しか返さない狭義の述語メソッドなので、その値に対して改めて真理値で場合分けしてfalse
とtrue
を返すようにしたり、二重否定を経由して同じfalse
とtrue
に戻ってくることには意味がありません。これを書いた人にこのことを指摘したら、頑なに拒絶されてしまったのが残念でした。他のプログラミング言語での習慣を引きずってしまったのかも知れません。こういった書き方は、折角のRubyの簡潔さを否定するものです。単に次のように書けば済みます。
%i[a b c].include?(:a)
false
とtrue
以外を返す広義の述語メソッドには、大抵の場合、似たような働きをする狭義の述語メソッドがあります。例えば、String#=~
の代わりにString#match?
を使ったり、File#size?
の代わりにFile#exist?
を使えば済むことです。
代替のない広義の述語メソッド、例えばFile.world_readable?
については、このまま論理演算に使っても一切不都合はありませんが、真理値を返すメソッドを新たに定義する中で使うために、false
とtrue
を返すことにこだわるなら、上のような方法を使うことにいくらかの正当化の余地はあります。
def foo ... !!File.world_readable?("/tmp/foo") end
false
, true
への不必要な変換
変数にpresent?
というRuby on Railsの述語メソッドを適用し、それをそのまま制御構造の条件部分で使っている次のようなコードを見ました。
[見かけたコード]
if foo.present?
present?
は偽や空オブジェクトなどをfalse
に、それ以外のオブジェクトをtrue
に変換します。
nil.present? # => false "bar".present? # => true
ここで、変数foo
は、present?
によって真理値が真から偽に変換されるような値(空オブジェクトなど)を取る可能性のないものでした。
Ruby on Railsを使わないコードでいうと、前の節で言及した!!
を使った次のようなコードと大体同じことをしています。
if !!foo
このような例の場合、上の節で述べたような、もとからfalse
やtrue
で値の変わらない写像とは異なり、値は変更されます。しかし、真理値には変更がない(偽は偽へ、真は真へと写される)ので、present?
や!!
を使わなかった場合と論理演算や条件分岐の結果に違いはありません。従って、これらも無駄です。無駄なものはなくしましょう。
if foo
&.
に置き換えられる制御構造
foo
が整数を表す文字列かnil
であり、false
になり得ない状況で、次のようなコードを見ました。
[見かけた書き方]
foo.to_i if foo
foo
がnil
である場合に返り値にnil
が欲しい場合でした。単に
foo.to_i
とすると、nil.to_i # => 0
によって0
が与えられてしまうので、そうは出来ません。
RubyのNull条件演算子&.
の存在は広く知られていますが、これをメソッド未定義エラーの回避にしか活用していない人がいます。そういう人は、&.
をRuby on Railsのtry
メソッドの代替もしくは後継にしか思っていないのかも知れません。そのような意識はきっぱり捨てるべきです。ここの例のような場合に&.
は有効です。
foo&.to_i
foo
がnil
の場合には、&.
によりto_i
の適用がスキップされ、nil
が返ります。
ただし、この用例では、Kernel#Integer
メソッドに対してRuby 2.6で導入されたexception
オプションを使って
Integer(foo, exception: false)
と書けば、foo
がnil
であるときばかりでなく、他の文字列以外のオブジェクトであるときや整数を表さない文字列の場合にもnil
を返すことが出来ます。
また次のように、メソッド連鎖に関係して&.
を中途半端に使っている例があります。
[見かけたコード]
foo&.bar ? foo.bar.baz : nil
1つ目のメソッドにのみ&.
を適用し、その後は諦めてしまったのでしょう。諦めずに最後まで&.
を使いましょう。
foo&.bar&.baz
無用なデフォルトのnil
こういうコードを良く見ます。
[見かけた書き方]
def foo(bar = nil) bar ||= default_bar ... end
def foo(bar: nil) bar ||= default_bar ... end
ここでは、随意的引数や随意的キーワード引数bar
のデフォルトを nil
にしておいて、その後、nil
を使うことなく、本来意図したデフォルト値default_bar
に置き換えています。特に、default_bar
が空オブジェクトや零の場合が、前の節で言及した、空オブジェクトや零で表されるべき値をnil
として保持しておき、後で空オブジェクトや零に置き換えるという場合です。
多くの場合、nil
はメタ的な値を表し、通常のケースでは使わない値なので、nil
を割り当てておけば、明示的に値を与えた場合と干渉しないという考えなのでしょう。しかし、なぜ一時的にでもnil
という値を与えておかなければならないのか分かりません。
初めから、意図したデフォルト値をメソッド定義の引き受け部に指定しておけば済むことです。
def foo(bar = default_bar) ... end
def foo(bar: default_bar) ... end
制御構造から暗黙的に与えられるnil
多くの制御構造やメソッドで、随意的な引数が省略されたときやある条件分岐の場合に、nil
が返されます。このようなRubyの仕様に頼らずに不必要にnil
に言及している例を挙げます。
[見かけた書き方]
some_condition ? foo : nil
条件some_condition
を満たさないときはnil
を返したいという意図ですが、if
が条件を満たさないときにnil
を返すことを使って、次のように書けます。
foo if some_condition
[見かけた書き方]
return nil
脱出系のreturn
, break
, next
では、引数がないときにはnil
が返されます。他の値を返す場合との対比を明示的に示すなどの理由がない限り、省略しましょう。
return
偽を広く扱う論理
nil
やfalse
に加えて空オブジェクトを偽と同様に扱う場合を見ます。
空オブジェクトの区別が不必要な場合
変数にnil
が付与されようとするときに、代わりに空文字列を与えるコードを見ました。その変数は後に文字列に埋め込むときだけに使われます。
[見かけた書き方]
string = some_method || "" ... "...#{string}..."
ここで空文字列という特別な文字列をnil
という偽の代わりに使っていて、つまり、空文字列を偽のように使っている訳です。
しかし、string
がnil
だったとしても、文字列埋め込み#{}
の中でnil.to_s # => ""
により空文字列が得られて同じ結果になるので、わざわざnil
を空文字列に置き換えておく必要はありません。
string = some_method ... "...#{string}..."
空配列に注意が必要な場合
ときに、空オブジェクトの場合に気を使わなければならないこともあります。
inject
で初期値を省略すると、イテレーションの最初の値が初期値として使われます。
[1, 2, 3].inject(1, :*) # => 6 [1, 2, 3].inject(:*) # => 6
レシーバーが空配列の場合に初期値を省略すると、イテレーションが行われず、初期値が分からないので、nil
が返ります。
[].inject(1, :*) # => 1 [].inject(:*) # => nil
これで良い場合は良いですが、1
を返したい場合には、レシーバーが空配列になり得るかどうかを考えて初期値1
を省略出来るか見極めないといけません。
特に、配列中の数値の和を計算したい場合には、専用のメソッドArray#sum
を使えば、空配列を気にする必要はありません。
[1, 2, 3].sum # => 6 [].sum # => 0
空オブジェクトの区別が必要な場合
空オブジェクトを他の真オブジェクトと区別して偽のように扱わなければならないときもあります。例えば、文字列の配列
strings # => ["", "foo", "", "bar"]
があり、これを", "
でつなげて表示したいとしましょう。単純にjoin
を使うと、次のようになります。
strings.join(", ") # => ", foo, , bar"
コンピューターが読み込むときにはこのような文字列を使うときもあるでしょうが、エンドユーザー向けの出力では普通は空白に", "
が続くこのような出力は不格好と考えられるでしょう。そこで次のようなコードを使うことになります。
strings.reject(&:empty?).join(", ") # => "foo, bar"
このように、空オブジェクトを、いずれ他のオブジェクトとは異なる仕方で扱うのであれば、あらかじめ偽値のnil
に置き換えておけばよいです。この例では、表示のステップなどで必要になった時にその場でするのではなく、空文字列を受け取った後の出来るだけ早い時点で、空文字列からnil
に変換しておけば良いです。
strings = [nil, "foo", nil, "bar"] ... strings.compact.join(", ") # => "foo, bar"
後で特別な扱いをするのなら、早いうちにnil
として持っておいたほうが使い勝手が良いです。このことを示す次の例があります。
[見かけた書き方]
... some_param.present? ... ... ... some_param.present? ... ... ... some_param.present? ...
some_param
はユーザーから受け取った文字列パラメーターです。このsome_param
は、コードの至るところでsome_param.present?
の形で制御構造の条件や論理演算の一部分として使われていました。present?
は前の節で説明したRuby on Railsの述語メソッドです。空文字列の場合のsome_param
を偽として扱うのにも関わらず、空文字列のまま保持しているために、論理の随所にそのことを引きずってpresent?
を適用しています。この場合も、出来るだけ早い時点で、空文字列からnil
に変換しておけば、そのようなことから開放されます。Ruby on Railsを使っている場合には、この目的のためにpresence
というメソッドがあります。これは、偽や空オブジェクトなどをnil
に、それ以外のオブジェクトはそのままにしておくメソッドです。
"".presence # => nil "bar".presence # => "bar"
Ruby on Railsを使っているならpresence
を使い、使っていないなら他の方法でこのようなパラメーターはできるだけ早いうちに変換しておきましょう。
some_param = some_param.presence ... ... some_param ... ... ... some_param ... ... ... some_param ...
偽を狭く扱う論理
nil
だけを偽として扱い、false
を真と同様に扱う場合を見ます。
nil
の区別が不必要な場合
foo
がfalse
値を取らない状況でnil
を明示的に場合分けしているコードをよく見ます。
[見かけた書き方]
if foo != nil
unless foo.nil?
false
の可能性を考慮しなくていいのなら、nil
とfalse
を区別する必要はありません。次のコードで足ります。
if foo
nil
の区別が必要な場合
変数foo
が随意的なパラメーターで、意味のある値としてfalse
を取り得、明示的に値が与えられていない場合にnil
を取るとき、明示的に値が与えられたかどうかを区別する必要があれば、nil?
を使います。
foo.nil?
レシーバーの真理値に基づかない論理
問題となっているオブジェクトの(真理)値からは場合分けに必要な情報が得られない場合があります。こういう場合は、オブジェクトの値ではなく何らかのメタな情報にアクセスする必要があります。
メモ化
一度計算した値を2回目以降に呼び出したときに、再度同じ計算をしなくて済むように、初回に計算した値を保存しておいて、2回目以降にはその値に言及するだけにする技法があり、これは「メモ化」と呼ばれます。
メモ化のつもりで次のように書いているコードに遭遇しました。
[見かけた書き方]
@foo ||= calculate_foo
これは、次のコードの省略記法であり、ほぼ等価です。メソッド+
などに基づく類似の省略形+=
などとは展開のされ方が異なることに注意して下さい。メソッドでない||
はここでは=
よりもスコープが広くなっています。
@foo || @foo = calculate_foo
インスタンス変数@foo
は未定義時にnil
を返すので、1回目にこのコードが呼び出されるとcalculate_foo
が評価され、@foo
に代入されます。問題は2回目からです。ここで登場するcalculate_foo
は、計算結果がnil
になる可能性のあるものでした。@foo
を1回目に計算した結果が真なる値であれば、2回目以降は論理和の短絡評価により@foo
が返り、calculate_foo
の計算や@foo
への代入が抑制されますが、1回目に計算した結果がnil
であれば、2回目以降もcalculate_foo
が計算され、@foo
に代入されます。つまり、メモ化が半分しか機能していません。もとのコードを書いた人にこのことを指摘しても、初めは理解してもらえませんでした。このコードのパターンを、よく見かけるからといって、理解せずに決まり事のように使ってしまっていたのだと思います。
@foo
が偽となる可能性がない場合には、上のようなコードで構いません。@foo
が未定義の場合のnil
値と@foo
がすでに計算されている場合の真値が論理和によって場合分けされます。
しかし、ここの例のように@foo
の計算結果が偽になり得る場合には、単なる論理和で@foo
が一度計算されたのかどうか判別できません。また、@foo
が未定義時のnil
と計算した結果がnil
になる場合が重なるので、@foo
の値によるいかなる場合分けによっても@foo
が計算済みかどうかを区別することがそもそも出来ません。この場合は、ずばり「インスタンス変数が定義されているかどうか」を調べるメソッドKernel#instance_variable_defined?
で判定します。
if instance_variable_defined?(:@foo) then @foo else @foo = calculate_foo end
ガードを使うのであれば、
def foo return @foo if instance_variable_defined?(:@foo) @foo = calculate_foo end
などとします。
ちなみに、上で省略記法||=
とその展開された形について、ほぼ等価と書きましたが、変数の初期化のタイミングに違いがあります。インスタンス変数@foo
やグローバル変数$foo
は、未定義のまま参照するとnil
が返るので、省略形でも展開された形でも違いを観察できませんが、ローカル変数foo
やクラス変数@@foo
は未定義のまま参照するとエラーを発生させるので、これらを使った場合、展開された形で初期化することは出来ません。
foo || foo = "default" # >> NameError: undefined local variable or method `foo' ... @@foo || @@foo = "default" # >> NameError: uninitialized class variable @@foo ...
ところが、省略記法を使うと、エラーが発生しません。
bar ||= "default" # => "default" @@bar ||= "default" # => "default"
このように、||=
は変数の初期化に関しては1つのトークンとして機能し、未定義により参照に失敗すると、直ちに値の付与に移行します。
ややこしいことに、ローカル変数の初期化は字句解析時に行われてnil
が与えられ、コードの評価とは関係ありません。ローカル変数が、字句の線的な順序に関して初めて登場するときに、それが値を付与される形であれば、評価時に未定義のままで呼び出されても、未定義エラーは起きません。従って、ローカル変数では、=
が||
よりも左側に来る次のような書き方も可能です。
baz = baz || "default" # => "default" @@baz = @@baz || "default" # >> NameError: uninitialized class variable @@baz ...
なぜなら、左辺のbaz
の字句解析によってbaz # => nil
の初期化がされ、その後に(=
の評価よりも前に)右辺のbaz
が評価中に呼び出されるからです。
ハッシュのキーの存在
Hash#[]
をあるキーに対して呼び出して条件分岐しているコードをよく見かけます。
[見かけた書き方]
hash # => {foo: 1, bar: 2} if hash[:baz]
この例ではキーに対する値に関心があるのではなく、キーがハッシュ中にあるかどうかを調べようとしています。
これが問題となる箇所で見かけた訳ではありませんが、この使い方には注意が必要です。ハッシュにキーが登録されていないときにHash#[]
で値を呼び出すと、nil
が返ります。従って、ハッシュにnil
が値として保持されている可能性がある場合には、Hash#[]
でハッシュの値を見ただけでは、nil
が保存されているのかキーがないのか区別できません。
hash_with_nil # => {foo: nil} hash_with_nil[:foo] # => nil hash_with_nil[:bar] # => nil
また、nil
の可能性がなくてもfalse
の可能性があるなら、少なくとも次のようにしてfalse
とnil
を区別しないといけません。
unless hash[:baz].nil?
しかし、nil?
によって真理値を反転しているので、制御構造の条件が逆になり、ややややこしくなります。
このような区別が問題になるときには、ずばり「ハッシュにキーが含まれているかどうか」を調べるメソッドHash#key?
で判定します。
hash_with_nil.key?(:foo) # => true hash_with_nil.key?(:bar) # => false
見かけた例は、次のように書けば、ハッシュの値がnil
になるときにも使えます。
if hash.key?(:baz)
述語メソッドに関係する間違った英語
述語メソッドの返り値を表す変数
述語メソッドを評価した値を変数に付与しておく場合に、その変数にも真理値を表すものであることをマーキングしたいことがあるようです。ところが、Rubyの仕様上、変数名を?
で終わらせることが出来ません。そこで、代替として、英語のbe動詞を使ってマーキングしているのをよく見かけます。
is_empty = foo.empty?
この例のように、be動詞の後に来る単語が名詞や、形容詞、前置詞ならいいのですが、これをむやみに適用して、次のように動詞などにまでbe動詞を付けているのを見かけます。
[見かけた書き方]
is_exist = foo.exist? is_include = bar.include?(baz)
これは英語として酷いです。受動態や進行形にするわけでもないのに、動詞の前にbe動詞は付けられません。余りに多くこのような例を見ました。
こういう場合は無理をせずに、そのままexist
, include
でよいと思います。
あるいは、これは筆者個人の考えですが、一般的にbe動詞を付ける必要がそもそもないのかも知れません。Rubyはダックタイピングを推奨しています。変数にも型はありません。さらに、いかなるオブジェクトも論理演算に使えるので、ブール型という概念の根拠が弱いです。変数が何を指し示しているのかはっきりしていれば、型、特に真理値を表すことをことさら強調する必要はないのではないでしょうか。
それではなぜ述語メソッドには?
を付けるのかという疑問が湧くかも知れません。もし実在する述語メソッドから?
を取ったnil
や、empty
、include
というようなメソッドがあったとすると、最も素朴な解釈は、レシーバーもしくは引数を「nil
にする」, 「空にする」, 「含める」というように、ある状態にしたり動作を行ったりするメソッドであると予測することだと思います。それに対してメソッド末尾に?
を付けると、自然とその単語の語尾の調子を上げて発音したくなり、「nil
なのか」, 「空なのか」, 「含むのか」という問い合わせであることがはっきりします。ここで、述語メソッドに対応する、yesかnoかを問う種類の疑問文は、問題の述語が文末の疑問符に比較的近いことが多く、さらに文末が上がり調子になります。つまり、疑問符を付けたメソッド名と、対応する疑問文の結びつきが自然です。
Is foo nil? Is foo empty? Does foo include bar?
一方、述語メソッド以外の問い合わせメソッドclass
, length
, values_at
などに対応する、実のある内容を問う、疑問詞を使った疑問文では、問題の部分が疑問詞であったり疑問詞とくっついたりして文頭に移動し、さらに文末が上がり調子になりません。このことから、述語メソッド以外のメソッドに使われる述語に疑問符を付けて出来たメソッド名と、対応する疑問文との結び付きが悪いと考えられます。
What class does foo belong to? How long is foo? (What is the length of foo?) What are the values of foo at bar?
だいたいそんなところから述語メソッドにだけ?
を付けるようになったと筆者は推測します。一方で、変数の場合には、ある状態にしたり動作を行ったりするという作用がないので、メソッドの場合のように問い合わせの場合を区別する必要がありません。従って、?
に相当するマーキングは不必要だと思います。
DSLで用意されたbe動詞の乱用
DSL、特にプログラマ以外の人も読むことが想定されているテストフレームワークには、コードを英語に近づけるために、メソッド名に細工したり、be動詞などをメソッドやメソッド名の一部として挿入できるようにしているものがあります。例えば、RSpecには、既存の述語メソッド名から?
を除いてbe_
を前に付けられるようにする仕掛けがあります。これを使って、例えばメソッドempty?
を基にして、
expect(foo).to be_empty
のようなそれなりに自然な英語としても読めるコードを作れます。ですが、include?
を基にして次のように書いているコードを見ました。
[見かけたコード]
expect(foo).to be_include bar
このようにもとから動詞であるincludeにbeを付けるのは、英語としてわざわざ間違った形にしているし、DSLの作者の意図にも反しています。RSpecのドキュメント2に、使用例として
expect(actual).to be_[arbitrary_predicate](*args)
という記述があります。be_
を任意の述語(この文脈では述語メソッド)に適用できるということなのですが、決して動詞に適用できるとは書いていません。動詞と述語の区別が明確でない人もいるかも知れないので、これを説明しておきます。
- 「動詞(verb)」は、単語を、形や他の単語と併用される条件に基いて分類した「品詞」の一つです。名詞や、形容詞、副詞などと対比される概念です。3
- 「述部(predicate)」(1単語のときは「述語」)は、文中の単語(の連続)を、意味上の役割に基いて分類した「機能」の一つです。主部や、修飾部などと対比される概念です。
- プログラマ、特にルビイストのいう「述語メソッド(predicate method)」は、前の節で述べたように、ある種の限られた返り値を持ち
?
で終わるメソッドのことです。ここでいう述語は、上記の一般的な意味での述語を狭めたものと見なせます。
上の用例では、emptyは形容詞であり述語です。includeは動詞であり述語です。RSpecでは述語メソッド名から?
を除いただけの形も出来るので、
expect(foo).to include bar
とします。
まとめ
真理値の周辺については、Rubyは他の多くのプログラミング言語と比較して、仕様の随所で簡潔に使えるようにデザインされています。しかし、コードを書くときに、他のプログラミング言語での習慣を引きずってしまって、それを活かし切れていないケースがあるのが残念でした。
最後に
マネーフォワードでは、エンジニアを募集しています。 ご応募お待ちしています。
【採用サイト】 ■マネーフォワード採用サイト ■Wantedly | マネーフォワード
【マネーフォワードのプロダクト】 ■自動家計簿・資産管理サービス『マネーフォワード ME』 iPhone,iPad Android
■「しら」ずにお金が「たま」る 人生を楽しむ貯金アプリ『しらたま』 iPhone,iPad
■おトクが飛び出すクーポンアプリ『tock pop トックポップ』
■金融商品の比較・申し込みサイト『Money Forward Mall』
■ビジネス向けクラウドサービス『マネーフォワードクラウドシリーズ』 ・バックオフィス業務を効率化『マネーフォワードクラウド』 ・会計ソフト『マネーフォワードクラウド会計』 ・確定申告ソフト『マネーフォワードクラウド確定申告』 ・請求書管理ソフト『マネーフォワードクラウド請求書』 ・給与計算ソフト『マネーフォワードクラウド給与』 ・経費精算ソフト『マネーフォワードクラウド経費』 ・マイナンバー管理ソフト『マネーフォワードクラウドマイナンバー』 ・資金調達サービス『マネーフォワードクラウド資金調達』
- http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-core/58498↩
- https://www.rubydoc.info/github/rspec/rspec-expectations/RSpec%2FMatchers:be↩
- 文法によっては、形や制限に加えて意味上の役割も品詞を定義する条件とすることがあります。それでも、品詞と機能が独立した概念であることに違いはありません。↩