マネーフォワード社内PRに見られるRubyの書き方について – (4) 真理値

エンジニアの澤田です。

この連載では、社内のRuby (on Rails)コードで気になった箇所の問題点やそこから発展して関連事項を議論しています。

前回投稿の マネーフォワード社内PRに見られるRubyの書き方について (3) では文字列の生成と検証を考察しました。
記事に言及してもらったり、コメントをもらったりして、励みになっています。

今回は真理値について考察します。


【バックナンバー】

題材とするコードは、社内のGitHubプルリクエストで実際に見かけたコードから問題点に関係する部分を抽出し、抽象化したもので、見かけたものそのままではありません。


 

今回は真理値の周辺を考察します。プログラミングにおいて真理値が何のためにあるかといえば、時に論理演算を施され、最終的に制御構造の条件部やメソッドに渡される引数やメソッド内のフラグなどとなって、場合分けに使われることがほとんどだと思います。従って、今回の真理値の考察には場合分けについての議論が多く登場します。

Rubyの論理演算で「偽」とされる値はfalsenilだけです。しかし、空文字列や、空配列、零などといったオブジェクトを実質的に偽のようにして場合分けしているコードも見かけます。このような場合分けの中には必要なものも必要でないものもあります。また反対に、オブジェクトの値falsenilを実質的に「真」と見なし、メタな評価基準を導入して偽を判定する必要のある場面もあります。このような、ユースケースごとの実質的な真理値のずれもまた真理値を考察するにあたっての大きな話題です。

本稿では、次の「真理値の定義」と「述語メソッド」の節で概念的なことを整理して、その後の節で問題のあるケースを見ていきます。

真理値の定義

本稿では、「真理値」という言葉を、Rubyオブジェクトの値を直接に指すものでなく、そのオブジェクトの論理演算や制御構造での振る舞いを表すものとして用い、「偽」か「真」の値を取るものとします。以下で、Rubyと他のプログラミング言語における真理値の定義を考察します。

Rubyの真理値

Rubyの論理演算ではfalsenilだけが偽の真理値で、他は真です。これには次のような理由があると考えられます。

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では、falsenil||の左単位元ですが、以下の結果から分かるように、(右)単位元でありません。

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と逆側の真理値になってしまったためです。

errorsnilでない場合の値が文字列、配列、ハッシュの場合は、それぞれ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?です。これらはメソッド名から想像されるインスタンスの数学的な同値関係に関係がなく、所属クラスを判定します。例えば、数学的には00.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}$ への所属」という言い方ではComplexInteger, 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)

二重否定!!は偽オブジェクト対真オブジェクトをfalsetrueに写像するためによく使われます。1つ目の!で偽オブジェクトをtrueに、真オブジェクトをfalseに写し、2つ目の!falsetrueを入れ替えます。

この例で使われているArray#include?のようなメソッドは、そもそもfalsetrueしか返さない狭義の述語メソッドなので、その値に対して改めて真理値で場合分けしてfalsetrueを返すようにしたり、二重否定を経由して同じfalsetrueに戻ってくることには意味がありません。これを書いた人にこのことを指摘したら、頑なに拒絶されてしまったのが残念でした。他のプログラミング言語での習慣を引きずってしまったのかも知れません。こういった書き方は、折角のRubyの簡潔さを否定するものです。単に次のように書けば済みます。

%i[a b c].include?(:a)

falsetrue以外を返す広義の述語メソッドには、大抵の場合、似たような働きをする狭義の述語メソッドがあります。例えば、String#=~の代わりにString#match?を使ったり、File#size?の代わりにFile#exist?を使えば済むことです。

代替のない広義の述語メソッド、例えばFile.world_readable?については、このまま論理演算に使っても一切不都合はありませんが、真理値を返すメソッドを新たに定義する中で使うために、falsetrueを返すことにこだわるなら、上のような方法を使うことにいくらかの正当化の余地はあります。

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

このような例の場合、上の節で述べたような、もとからfalsetrueで値の変わらない写像とは異なり、値は変更されます。しかし、真理値には変更がない(偽は偽へ、真は真へと写される)ので、present?!!を使わなかった場合と論理演算や条件分岐の結果に違いはありません。従って、これらも無駄です。無駄なものはなくしましょう。

if foo

&.に置き換えられる制御構造

fooが整数を表す文字列かnilであり、falseになり得ない状況で、次のようなコードを見ました。

[見かけた書き方]
foo.to_i if foo

foonilである場合に返り値にnilが欲しい場合でした。単に

foo.to_i

とすると、nil.to_i # => 0によって0が与えられてしまうので、そうは出来ません。

RubyのNull条件演算子&.の存在は広く知られていますが、これをメソッド未定義エラーの回避にしか活用していない人がいます。そういう人は、&.をRuby on Railsのtryメソッドの代替もしくは後継にしか思っていないのかも知れません。そのような意識はきっぱり捨てるべきです。ここの例のような場合に&.は有効です。

foo&.to_i

foonilの場合には、&.によりto_iの適用がスキップされ、nilが返ります。

ただし、この用例では、Kernel#Integerメソッドに対してRuby 2.6で導入されたexceptionオプションを使って

Integer(foo, exception: false)

と書けば、foonilであるときばかりでなく、他の文字列以外のオブジェクトであるときや整数を表さない文字列の場合にも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

偽を広く扱う論理

nilfalseに加えて空オブジェクトを偽と同様に扱う場合を見ます。

空オブジェクトの区別が不必要な場合

変数にnilが付与されようとするときに、代わりに空文字列を与えるコードを見ました。その変数は後に文字列に埋め込むときだけに使われます。

[見かけた書き方]
string = some_method || ""
...
"...#{string}..."

ここで空文字列という特別な文字列をnilという偽の代わりに使っていて、つまり、空文字列を偽のように使っている訳です。

しかし、stringnilだったとしても、文字列埋め込み#{}の中で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の区別が不必要な場合

foofalse値を取らない状況でnilを明示的に場合分けしているコードをよく見ます。

[見かけた書き方]
if foo != nil
unless foo.nil?

falseの可能性を考慮しなくていいのなら、nilfalseを区別する必要はありません。次のコードで足ります。

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の可能性があるなら、少なくとも次のようにしてfalsenilを区別しないといけません。

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や、emptyincludeというようなメソッドがあったとすると、最も素朴な解釈は、レシーバーもしくは引数を「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』

くらしの経済メディア『MONEY PLUS』

■ビジネス向けクラウドサービス『マネーフォワードクラウドシリーズ』
バックオフィス業務を効率化『マネーフォワードクラウド』
会計ソフト『マネーフォワードクラウド会計』
確定申告ソフト『マネーフォワードクラウド確定申告』
請求書管理ソフト『マネーフォワードクラウド請求書』
給与計算ソフト『マネーフォワードクラウド給与』
経費精算ソフト『マネーフォワードクラウド経費』
マイナンバー管理ソフト『マネーフォワードクラウドマイナンバー』
資金調達サービス『マネーフォワードクラウド資金調達』


  1. http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-core/58498 ↩︎

  2. https://www.rubydoc.info/github/rspec/rspec-expectations/RSpec%2FMatchers:be ↩︎

  3. 文法によっては、形や制限に加えて意味上の役割も品詞を定義する条件とすることがあります。それでも、品詞と機能が独立した概念であることに違いはありません。 ↩︎

Pocket