Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

マネーフォワード社内PRに見られるRubyの書き方について – (5) 文の環境

エンジニアの澤田です。

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

4回目の マネーフォワード社内PRに見られるRubyの書き方について - (4) 真理値 では真理値を考察しました。

今回は 文の環境について考察します。前回から間が空いた分、今回は長いです。説明の割合が多くなってしまったことをご容赦下さい。


【バックナンバー】

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


文はその書かれる場所によって特有の仕方で評価されます。本稿では、文の生じる場所を「文の環境」、あるいは単に「環境」と呼ぶことにします。今回は、文の環境の持つ性質の内で、システマチックに定まるもの、即ち「複数の文を記述できるか」、「評価のタイミング」、「外のローカル変数を参照出来るか」及びそれらと結びついた他の性質について論じます。

環境に付随する性質の内で 「selfの値」、「メソッド定義式の定義先」、「定数参照の名前空間のデフォルト」、「break, next, returnの振る舞い」などはシステマチックに決まらず、「どのメソッドに付随するコードブロックの中か」というような細かい条件に依存するので、今回は扱いません。

 

文の環境の種類

いかなる構文の内部でもない素の環境を最も基本的な文の環境であると考えることにします。そこでは

  • 複数の文を書くことが出来、
  • コードが即時に評価され、
  • それより前に素の環境に書かれたローカル変数を参照することが出来ます。

これらの性質について後の節で詳述します。そして、他の文の環境はこれらの性質を満たしたり満たさなかったりします。そこで、文の環境を「 複数の文を書くことが出来るか」、「 評価されるタイミング」、「外のローカル変数を参照出来るか」という3つの切り口から体系的に分類してみます。分類されたそれぞれの種類について、本稿では以下のように①–⑧の名前を付けて呼びます。

文の環境の種類 複数の文の記述 評価のタイミング 外のローカル変数の参照
即時
即時 不可
随時
随時 不可
不可 即時
不可 即時 不可
不可 随時
不可 随時 不可

これらの種類に該当する文の環境を以下に列挙します。種類ごとにa, b, ... と名前を付けています。各環境のコード例の...の部分が該当します(ただし、⑤cの範囲式の両端のみで表します)。環境は網羅したつもりですが、コード例は網羅的でありません。

種類①

a. 素の環境

...

b. 文字列補完

"#{...}"
/#{...}/
:"#{...}"

c. 括弧の内部

(...)

d. 予約語の制御構造の本体

begin ... end
begin foo; rescue then ... end
begin foo; rescue then bar ensure ... end
if foo then ... end
case foo when bar then ... end
while foo do ... end

種類②

a. モジュール定義式やクラス定義式の本体

module A; ... end
class B; ... end

種類③

a. ラムダリテラルの本体

-> do ... end
->{...}

b. コードブロックの本体

foo do ... end
foo{...}

種類④

a. (特異)メソッド定義式の本体

def foo() ... end

種類⑤

a. 予約語の制御構造の引数

begin foo; rescue ... then bar end
if ... then bar end
case ... when bar then baz end
case foo when ... then baz end
while ... do bar end
foo do break ... end
foo do next ... end
def foo() return ... end

b. 修飾式の本体や引数、予約語の演算子の項

... if foo
... while foo
... rescue foo
bar if ...
bar while ...
bar rescue ...
not ...
... and bar
foo and ...
... or bar
foo or ...

c. 条件演算子の項、(自己)代入される値、範囲式の両端、クラス定義式の親クラス

... ? bar : baz
foo ? ... : baz
foo ? bar : ...
foo = ...
foo ||= ...
〜..bar
foo..〜
class A < ...; end

d. メソッドや super, yieldの括弧のない呼び出しの引数

foo ...
yield ...

e. メソッドやsuper, yieldの括弧のある呼び出しの引数

foo(...)
yield(...)

f. 配列リテラルの要素やハッシュリテラルのキーと値

[...]
{... => bar}
{foo => ...}

種類⑥

該当なし

種類⑦

a. 括弧のないラムダリテラルの引数のデフォルト

->a = ... do bar end

b. 括弧のあるラムダリテラルの引数のデフォルト

->(a = ...) do bar end

c. コードブロックの引数のデフォルト

foo do |a = ...| bar end

種類⑧

a. 括弧のない(特異)メソッド定義式の引数のデフォルト

def foo a = ...; end

b. 括弧のある(特異)メソッド定義式の引数のデフォルト

def foo(a = ...) end

以下の各節では、3つの切り口「「複数の文を書くことが出来るか」、「評価されるタイミング」、「外のローカル変数を参照出来るか」について順に詳述し、その途中で気になるコード例を検討したり、関連のある他の性質について考察したりします。

 

複数の文を書くことが出来るか

環境の種類ごとの違い

複数の文を直接(= 括弧や制御構造など他の環境で包まずに)書くことの出来る環境と出来ない環境があります。(Rubyの実装の用語では前者がstmts (statements)、後者がexpr (expression)に対応することを卜部昌平氏に指摘してもらいました。)1つの環境に複数の文を書くときには、文の間に改行かセミコロンを入れて区切ります。複数の文を書けない環境には、改行やセミコロンが文ばかりでなくその環境をも区切って終わらせてしまうから出来ないものと、改行やセミコロンを書いただけで統語エラーになるから出来ないものとがあります。

これについて次の節で細かく論じます。細部に関心がない場合は、環境①–④では複数の文を書くことが出来、⑤–⑧では出来ないという要点だけ心に留めて下さい。

1. 複数の文を書くことが出来る環境

①–④は、複数の文を改行やセミコロンで区切って書くことが出来ます。例えば、次のように書けます。

"#{
  sleep(0.1)
  "foo"
}"
"#{sleep(0.1); "foo"}"

他の文の環境でも同様の例で確かめることが出来ます。

2. 改行やセミコロンで区切られる環境

⑤a–d, ⑧aでは、改行やセミコロンで文の環境が区切られ、その片側はその文の環境には属しません。例えば、

unless sleep(0.1)
  false
  "foo"
end

sleep(0.2)
true ? "foo" : "bar"

baz =
  sleep(0.1)
  "baz"

def foo a = sleep(0.1)
  "bar"
  a
end
unless sleep(0.1); false; "foo" end

sleep(0.2); true ? "foo" : "bar"

baz = sleep(0.1); "baz"

def foo a = sleep(0.1); "bar"; a end

では、unlessの後で改行やセミコロンで区切られたsleep(0.1)falseのうち、sleep(0.1)だけが引数でfalseは本体の一部です。sleep(0.2)は条件演算子... ? ... : ...の条件の一部ではありません。"baz"は変数bazへの代入には関わっていません。"bar"は引数aのデフォルトの指定には関わりがなく、メソッド定義の本体の一部です。

これらの環境が改行やセミコロンで区切られているにも関わらず、さも区切られていないかのように構文を続けると、場合によっては、後続する環境で辻褄が合わなくなり、統語エラーになることがあります。

unless sleep(0.1); false then "foo" end # >> syntax error
false ? sleep(0.1); "foo" : "bar" # >> syntax error
a = "foo"
case sleep(0.1); a
when "foo" then puts "foo"
end # >> syntax error

⑦b, cは、セミコロンがあればそこで終わり、それに続く部分は、ブロックローカル変数の宣言になります(ブロックローカル変数は、後の節で詳述します)。次のコード

a = "foo"
->(b = "bar"; a) do b end.call() # => "bar"
a = "foo"
instance_exec() do |b = "bar"; a| b end # => "bar"

では、「"bar"; aが評価され、最後のaが外のローカル変数を参照し、bのデフォルトが"foo"になる」というようにはいかず、実際には、デフォルトがセミコロンで終わるため、"bar"bのデフォルトとして評価され、続くaは、外の同名のローカル変数とは無関係なブロックローカル変数の宣言になります。

セミコロンの後にブロックローカル変数の宣言以外のものを書くと、ブロックローカル変数の宣言の環境でつじつまが合わず、統語エラーになります。さらに、セミコロンを書いた後に宣言をしないときにも統語エラーになります。

また、⑦bと似ている⑦aの後には、セミコロンでブロックローカル変数の宣言を行うことが出来ません。

a = "foo"
->b = "bar"; a do b end # >> syntax error

3. 改行やセミコロンを含めると統語エラーになる環境

⑤e, f, ⑧bには、改行やセミコロンを入れると統語エラーになります。

p(sleep(0.1); "123") # >> syntax error

[sleep(0.1); "foo"] # >> syntax error

def foo(a = sleep(0.1); "bar") a end # >> syntax error
p(
  sleep(0.1)
  "123"
) # >> syntax error

[
  sleep(0.1)
  "foo"
] # >> syntax error

def foo(
  a = sleep(0.1)
  "bar"
) a end # =>> syntax error

文の環境に隣接した改行

前節では、1つの環境の中で複数の文を区切る改行やセミコロンを観察しました。それとは別のこととして、これらの文の環境に隣接する位置(= 端)で改行を入れることが出来ることがあります。ここで入れる改行をセミコロンに置き換えることは出来ません。紛らわしいので、ここでいくつか取り上げます。

括弧類やコードブロックの受け取り部のパイプの対の内側に改行を入れることが出来ます。

p(
  "foo"
)

{
  "foo" => "bar"
}

->(
  a
) do end

instance_exec() do |
  a
| a end

def foo(
  a
) a end

コンマや⑤cの条件演算子の?、⑤cの代入や自己代入の=、⑤fのハッシュロケットの後に改行を入れることが出来ます。

p("123",
"456")

->a = "foo",
b do b end

def foo(
  a = "bar",
  b
) a end

true ?
  "foo" : "bar"

foo =
  1

{"foo" =>
"bar"}

⑤cの条件演算子の:の前後に改行を入れることが出来ます。

true ? "foo"
  :
  "bar"

修飾式の本体に複数の文と同等の内容を書く

以上、細かい説明になりました。この節では問題のあるコードを見ます。

⑤bの修飾式の本体は、複数の文を書くことが出来ない環境ですが、そこに複数の文と同等の内容を書こうとしているコードがよくあります。

[見かけた書き方]

do_something and do_another_thing if some_condition

特に、Ruby on Railsのコントローラーの文脈で

[見かけた書き方]

render "foo" and return if some_condition

のような書き方を見かけます。ここでは返り値は問題ではなく、複数の文を無理矢理予約語の論理演算子andで繋いで1つの文にすることによって、複数の文を書くことの出来ない環境に押し込んでいます。どうやら、一部のRuby on Rails関連のドキュメントなどにこのような書き方が載っているようで、実は困ったものだと思っています。

何が問題かというと、まず、表現の様式を変えてしまっています。逐次評価されるべき文は改行(やセミコロン)で区切って順に書くのが標準的な書き方です。それなのに、この部分だけいきなりandで文と文をつないでいます。もし逐次評価される文をこのような論理演算で繋げて書くことにするのなら、プログラム全体をそうしなければおかしいです。

しかし、それは決してしないでしょう。その理由は2つ目の問題点でもありますが、それは、返り値が使われないのに、意味のない論理演算をすることは、わざわざ効率を落とすことになるからです。

3つ目の問題点は、論理の非対称性です。andは予約語です。従って、メソッドとは違って、引数を評価する前に効果を発揮することが出来ます。殊にandでは短絡評価が働き、前の文を評価した返り値が真でない限り、後の文が評価されません。たまたまrenderなどの真を返すメソッドを使っているときにうまくいっているだけです。もし1つ目の文にputsを使っていたとしましょう。このときputsnilを返すので、

puts(something) and do_another_thing if some_condition

としたのでは、do_another_thingが評価されません。では、真を返すメソッドか偽を返すメソッドかに応じてandorを使い分ければ良いのでしょうか。プログラマがいちいちそんなことを考えて構文を変えるのは、間違いのもとですし、それだけの思考をするに見合うだけの恩恵がありません。さらに、メソッドによって真偽が決まるわけでもありません。例えば、p(foo)は、fooが真なら真を返し、偽なら偽を返します。fooの値によって構文を場合分けするのでしょうか。それでは本末転倒です。

論理の非対称性だけが問題なら、メソッドで文を繋げることが考えられるかも知れません。メソッドでは引数の短絡評価が起きません。しかし、広範囲のオブジェクトに定義されていて、広範囲のオブジェクトを引数として取ることが出来、副作用のないメソッドを探す必要があります。そのようなメソッドは見当たりません。例えば、メソッド&を使って、1項目の文の真偽に関わらず2項目の文を評価することが出来ます。

false & puts("foo") if true
# >> foo
# => false

true & puts("foo") if true
# >> foo
# => false

しかし、1項目が文字列であった場合に破綻を来します。

"bar" & puts("foo") if true
# >> foo
# >> NoMethodError: undefined method `&' for "bar":String

それに、他の問題点が残ります。

このような状況でどうしても修飾式を使いたいのならば、問題となっている複数の文をセミコロンで繋ぎ、複数の文を書くことの出来る環境で包むことで、問題点全てが解決します。特に、①cの括弧(...)の構文は、専ら複数の文をまとめるためにあるので、好都合でしょう。

(do_something; do_another_thing) if some_condition

あるいは、①dのbegin ... endも使えます。

begin do_something; do_another_thing end if some_condition

もし、これらを不格好だと感じるのなら、修飾式を使うべきでないと思います。そもそも、修飾式は、引数や本体がそれぞれ1行の1 文で書けるときのためのものであり、1文で書けないのなら、適していません。

最も良いのは、素直に複数の文を許容する①dの制御構造の本体に書くことです。その場合、セミコロンを使って複数の文を1行に書くことが出来ます。ここで、条件の区切りにthenを避けて、常に改行やセミコロンを使う人がいます。しかし、今の場合にセミコロンを使うと、

if some_condition; do_something; do_another_thing end

となり、1つ目のセミコロンは条件の区切りを示し、2つ目のセミコロンは本体の中の文の区切りを示すという異なる役割を担うことになり、見間違いのもととなり得ます。このような状況では、thenを使うことによって、セミコロンの繰り返しを和らげることが出来ます。

if some_condition then do_something; do_another_thing end

複数の文の最後の値を定数や変数に代入する

もう一つたまにあるのが、複数の文を書くことが出来ない環境⑤cの代入される値に複数の文と同等の内容を書こうとしている例です。社内のPRで、メモ化のために、次のようにしている例がありました。

[見かけた書き方]

@foo ||= ->{
  ...
}.call

@fooを得るための計算が複数の文に渡るが、@fooが既に計算済みの場合にはそれら複数の文をまるごとスキップしたいという状況です。

代入される値の位置には複数の文を書くことが出来ないために、複数の文を書くことが出来る環境で包もうという発想のもと、ラムダオブジェクトを使っているようです。

後で詳述するように、ラムダオブジェクトの中で新たに宣言されたローカル変数は、外からは参照出来ないので、ラムダオブジェクトを作ることによって、そのようなローカル変数の干渉を心配せずに使えるという利点はあります。しかし、PRにあったコード例では、干渉を起こすローカル変数はなく、ラムダオブジェクトを使う必然性はありませんでした。

そして、ラムダオブジェクトは保存して後で使うことなく、直ちに評価して終わりにしています。これではラムダオブジェクトを生成することが無駄です。

このような場合には、複数の文を包んで1つの文にすることだけが目的なので、前節と同様、①cの(...)もしくは①dのbegin ... endを使うのが適切でしょう。

@foo ||= (
  ...
)
@foo ||= begin
  ...
end

これを思いつかなかった原因として、括弧式や制御構造がオブジェクトであり、返り値を持つということを忘れているのかも知れません。

あるいは、逆に、①dの制御構造の本体に代入式を埋めことも出来ます。

if @foo then @foo
else
  ...
  @foo = ...
end

また、別のところで、次のようになっているコードがありました。

[見かけた書き方]

@foo ||= begin
  if ...
    ...
  end
end

この場合は、if ... endが返り値を持つことを忘れつつも、begin ... endが返り値をもつことを中途半端に意識していたのでしょうか。begin ... endは冗長で、直接if ... endと出来ます。

@foo ||= if ...
  ...
end

 

評価されるタイミング

評価のタイミングについては、

module A; puts "Hello" end

を評価すると直ちに画面に出力Helloが得られることから、②のモジュールの本体は即時に評価されることが分かります。

①d, ⑤a–cは、ものによってはちょうど1度評価されるとは限らず、評価がスキップ(= 短絡評価)されたり繰り返されたりすることもありますが、いずれにしても即時に行われます。

⑤d, eの引数の評価は、以下に観察されるように、レシーバーの評価よりも後でメソッド(やsuper, yield)の探索や評価よりも前に行われます。引数は即時に評価されます。メソッドの影響を受けて評価がスキップしたり繰り返されたりすることはありません。

def foo; puts "foo" end
public def bar(_) puts "bar" end
def baz; puts "baz" end

foo.bar(baz)
# >> foo
# >> baz
# >> bar

ただし、&.によるメソッド呼び出しでレシーバーがnilの場合には、メソッドの評価が中断されるばかりでなく、引数も評価されません。

foo&.bar(baz)
# >> foo

①, ②, ⑤の他の環境でも、評価は即時に行われます。

一方で、④aの(特異)メソッド定義式の本体について、

def foo() puts "Hello" end

を評価しただけでは画面に何も出力されず、この後でfooメソッドを呼んだタイミングで Helloという画面出力が得られることから、即時ではなく随時に評価されることが分かります。③bのコードブロックの本体について、例えばinstance_execというメソッドは、コードブロックをその場で評価しますが、それはこのメソッド固有の振る舞いによるものであり、コードブロックが評価されるか否か、されるならどのタイミングでされるかということは、そのコードブロックを渡されるメソッドや状況にかかっています。従って、コードブロックの本体の環境自体は随時に評価されると言えます。同様に、③, ④, ⑦, ⑧の他の環境も随時に評価されます。

繰り返し評価される文の一部を予め1度だけ評価する

評価のタイミングに関して、必ずしも弊社のコードにあったわけではありませんが、次のようなコードを見かけることがあります。

[見かけた書き方]

def generate_id
  def id; rand end
end

generate_id
id # => 0.6126952045885906
id # => 0.07886627576923644

ここでは、何度呼んでも同じ乱数値を返すidというメソッドを定義するgenerate_idというメソッドを定義しようとしています。一旦generate_idを呼んだら、idの返す値が固定されることを意図しています。しかし、実際には、idを呼ぶ度に④aに属するidメソッドの定義式の本体のrandが評価し直され、意図が失敗しています。

同様に、メソッド名を動的に決められるように一般的した次のコードでも、③bに属するdefine_methodのコードブロックの本体がidの呼び出しの度に評価されてしまっています。

[見かけた書き方]

def generate_parameter(name)
  define_method(name){rand}
end

generate_parameter(:id)
id # => 0.8488184696826421
id # => 0.5369724044326061

これらのコードは、メソッドidの定義時にrandが即時評価されて値が固定されるという錯覚におちいって書かれているようです。

目的を満たすコードにするには、randをメソッド定義式の本体やdefine_methodのコードブロックの外に出す必要があります。例えば後者では、

def generate_parameter(name)
  parameter = rand
  define_method(name){parameter}
end

generate_parameter(:id1)
generate_parameter(:id2)
id1 # => 0.8403718086382812
id1 # => 0.8403718086382812
id2 # => 0.8749708867120753
id2 # => 0.8749708867120753

上のようにすると、generate_parameter呼び出し中のid1id2のメソッド定義直前にparameter = randが即時評価され、id1id2の定義中でそれぞれに固定された値が使われます。

もうお分かりかと思いますが、念のために書いておくと、即時や随時というタイミングは相対的なものです。上の最後のコードで、generate_parameterのメソッド定義式の本体の中にある文は、generate_parameter定義時を基準にすれば随時評価されると言えますが、その後generate_parameterが呼ばれ、generate_parameterの定義式の本体が評価されている時を基準にすれば、即時評価されると言えます。

同じ内容の別オブジェクトからなる配列を生成する

弊社のPRにあったかどうかは覚えていませんが、Ruby初心者にありがちな間違いが、同じ内容だが別である複数のオブジェクトからなる配列を作ろうとして、次のようにするコードです。

[見かけた書き方]

array = Array.new(3, "foo") # => ["foo", "foo", "foo"]

この後に、1つの要素だけを改変しようとして、全ての要素が同じように改変されていることに戸惑うというパターンです。

array[0] << "bar"
array # => ["foobar", "foobar", "foobar"]

なぜそうなるのか。arrayを生成したコードの"foo"は、⑤eのメソッド呼び出しの引数であり、リテラルが即時評価されて文字列インスタンスが生成されます。newメソッドの呼び出しは配列の要素の数に関わりなく1度しか行われていないので、配列の生成に使える材料は、即時評価されたその1つのインスタンスのみです。従って、メソッドArray.new の内部実装がどうなっていようとも、(メソッド内で理不尽にも引数"foo" を複製しない限り、)このコードで生成される配列の要素は同じオブジェクトの繰り返しにしかなり得ないことが分かるでしょう。

ちなみに、次のようなコードも見かけます。

[見かけた書き方]

array = ["foo"] * 3 # => ["foo", "foo", "foo"]

これは同じ問題を抱えている上に、一時的な配列を作り、メソッド*を適用し、元の配列を捨てるというステップをわざわざ踏んでいる、より問題のあるコードです。

Array.newの仕様を読むまでもなく、配列の各要素を別のオブジェクトにするには、少なくとも、"foo"を随時評価される環境に書くしかないことが分かります。③bのコードブロックの本体は随時評価されますが、Rubyはうまく出来ていて、このような目的のために、Array.newにコードブロックを与えると、要素の1つ1つについて、その度にコードブロックの本体を評価して返り値を要素の値とするようになっています。

array = Array.new(3){"foo"} # => ["foo", "foo", "foo"]
array[0] << "bar"
array # => ["foobar", "foo", "foo"]

必要に応じてArray.newに第2引数を与えるかコードブロックを与えるかを使い分ける必要があります。

置換文字列の中のマッチ

これも弊社のPRにあったかどうか定かではありませんが、Ruby初心者がたまにする間違いです。

[見かけた書き方]

"a b c".gsub(/\w/, "(#$&)") # => "() () ()"

マッチした部分を置換文字列の中で使おうとして、置換文字列の中で文字列補間を使い、それをメソッドの第2引数として渡しています。マッチした部分を表す$&が置換箇所ごとに"a", "b", "c"と変化していくという期待に反して、3回とも空白(実はnil)になっています。

⑤eのメソッド呼び出しの引数が即時評価されることを理解していれば、少なくとも置換文字列の値が置換操作の最中に変化することはないことが分かります。さらに、引数の評価がメソッドの評価よりも前に行われることを理解していれば、第2引数の値がマッチの結果に依存し得ないことが分かります。現実に、出力の$&に相当する部分は、この文が呼ばれる前の状態を反映しています。

前節と同様、置換文字列を置換操作の最中にマッチした結果に依存しながら変化させるには、少なくとも、置換文字列を③bのコードブロックの本体のような随時評価される環境に書くしかないことが分かります。ここでもRubyはうまく出来ていて、gsub, sub及びその破壊版のメソッドは置換文字列をコードブロックとしても受け取ることが出来、その場合にはメソッド実行中のマッチの結果を反映します。

"a b c".gsub(/\w/){"(#$&)"} # => "(a) (b) (c)"

また、別の方法として、第2引数で置換文字列を直接与えるのではなく、メソッドの実行の最中に置換文字列に展開されるようなメタ文字列を与えることも出来ます。そうするには、特別な意味を与えられた文字を特殊文字のごとくエスケープして(= "\\&"など)文字列の中に埋め込みます。

"a b c".gsub(/\w/, "(\\&)") # => "(a) (b) (c)"

 

外のローカル変数を参照出来るか

①, ③, ⑤, ⑦から外で宣言されたローカル変数を参照出来ることを、いくつかの例で示します。まずは①c, dの例です。

foo = "foo"
(foo) # => "foo"
bar = "bar"
if true then bar end # => "bar"

次に、③は随時評価されるので、callを使ってラムダリテラルを評価したり、intance_execを使ってコードブロックをちょうど1度評価したりして、観察します。

foo = "foo"
-> {foo}.call() # => "foo"
foo = "foo"
instance_exec(){foo} # => "foo"

⑤aでは、引数が評価されるように制御構造の残りの部分を工夫します。

bar = ArgumentError
begin Integer(""); rescue bar then "baz" end # => "baz"
foo = true
if foo then "bar" end # => "bar"
foo = true
while foo do break "bar" end # => "bar"
foo = "foo"
loop do break foo end # => "foo"
def foo() bar = "bar"; return bar end; foo # => "bar"

⑦でも確認出来ます。

foo = "foo"
->bar = foo{bar}.call() # => "foo"
foo = "foo"
instance_exec(){|bar = foo| bar} # => "foo"

ここに挙げなかった①, ③, ⑤, ⑦の環境についても、同様にして、外で宣言されたローカル変数を参照出来ることを確かめることが出来ます。

一方で、②, ④, ⑧からは外で宣言されたローカル変数を参照出来ません。

foo = "foo"
module A; foo end # >> undefined local variable or method `foo'
foo = "foo"
def bar() foo end; bar # >> undefined local variable or method `foo'
foo = "foo"
def bar(baz = foo) baz end; bar # >> undefined local variable or method `foo'

外からローカル変数を参照出来るか

環境外のローカル変数を中から参照出来るかどうかについては、既に観察しました。これに対して、環境内で作ったローカル変数を外から参照出来るかというのは別の問題です。

Rubyでは、外のローカル変数を参照出来る環境の一部が、外からのローカル変数の参照を許す環境になっています。その追加の条件とは、即時評価される環境であることです。次の表のようになっています。

文の環境の種類 評価のタイミング 外のローカル変数の参照 (参照先: 外, 参照元: 中) 外からのローカル変数の参照 (参照先: 中, 参照元: 外)
①, ⑤ 即時
②, ⑥ 即時 不可 不可
③, ⑦ 随時 不可
④, ⑧ 随時 不可 不可

これは、よく考えると、納得出来ることです。ある環境を評価するときは、それを囲む外の環境のその位置までコードを読み進んで評価が済んでいるはずです。従って、外の文脈(ローカル変数の値など)を中の環境に曖昧なく引き継ぐことが可能で、このとき即時評価(①, ⑤)か随時評価(③, ⑦)かは関係ありません。一方、ある環境の中に別の環境が含まれているとき、中の方の環境が即時評価されない限り、そこから文脈を取り出して外の環境に曖昧なく引き継ぐ事は出来ません。一般には即時評価されない環境が、たまたま即時に評価される場合(例えばラムダリテラルをその場でcallする場合)もありますが、そのようなことはコードを実行してみないと分からず、構文からは判断できないし、また個別的なことを考慮すると複雑になるので、即時評価されない環境(③, ⑦)のローカル変数を外から参照することは一律に不可にしているのでしょう。

このことを観察してみましょう。まず、①, ⑤で値を代入されたローカル変数は、外から参照することが出来ます。以下は一例です。

(foo = "foo")
foo # => "foo"
if true then foo = "foo" end
foo # => "foo"
def foo()
  return (bar = "bar"; raise)
rescue
  bar
end; foo # => "bar"
if bar = "bar" then end
bar # => "bar"
foo = "foo" if true
foo # => "foo"
(foo = "foo").."bar"
foo # => "foo"
p bar = "bar"
bar # => "bar"

その一方で、②–④, ⑦, ⑧で値を代入されたローカル変数を外から参照しようとするとエラーが発生します。

module A; foo = "foo" end
foo # >> undefined local variable or method `foo'
->{foo = "foo"}.call()
foo # >> undefined local variable or method `foo'
instance_exec(){foo = "foo"}
foo # >> undefined local variable or method `foo'
def bar() foo = "foo" end; bar
foo # >> undefined local variable or method `foo'
->a = foo = "foo"{}.call()
foo # >> undefined local variable or method `foo'
instance_exec(){|a = (foo = "foo")|}
foo # >> undefined local variable or method `foo'
def foo(a = bar = "bar") end; foo
bar # >> undefined local variable or method `bar'

ローカル変数の宣言と値の付与

外でローカル変数を作り、環境内で同名のローカル変数に値を代入した後で、再び外からその名前のローカル変数を参照すると、一部の環境でややこしいことが起きます。

まず、外のローカル変数を参照出来ない環境②, ④, ⑧では、難しいことは起きません。次の例では、始めに環境外でローカル変数に"outer"が代入され、環境内で同名のローカル変数に"inner"が代入されますが、環境の外で問題のローカル変数を参照したときには、外で与えた"outer"が返されています。環境内のローカル変数とは別物であることが分かります。

foo = "outer"
module A; foo = "inner" end
foo # => "outer"
foo = "outer"
def bar() foo = "inner" end; bar
foo # => "outer"
bar = "outer"
def foo(a = bar = "inner") end; foo
foo # => "outer"

次に、環境の中と外で双方向にローカル変数を参照出来る①, ⑤の場合も簡単です。下の例では、始めに環境外で"outer"を代入したローカル変数そのものを環境内で値"inner"に上書きし、最終的にその値が参照されています。

bar = "outer"
if true then bar = "inner" end
bar # => "inner"
bar = "outer"
{foo: bar = "inner"}
bar # => "inner"

この場合、前節で観察したように、1行目のbar = "outer"は冗長で、削除しても結果は変わりません。

そして、難しいのが、一方向にのみローカル変数を参照出来る環境③, ⑦の場合です。同じようなコードで観察すると、環境内で与えられた"inner"が最後に参照されます。

foo = "outer"
->{foo = "inner"}.call()
foo # => "inner"
foo = "outer"
instance_exec(){foo = "inner"}
foo # => "inner"
foo = "outer"
->a = foo = "inner"{}.call()
foo # => "inner"
foo = "outer"
instance_exec(){|a = (foo = "inner")|}
foo # => "inner"

一見すると、①, ⑤と同じように思えるかも知れません。しかし、①, ⑤と違って、1行目のfoo = "outer"が役割を果たしていないわけではありません。前節で観察したように、この行を削除すると、変数もしくはメソッド未定義エラーになります。中のローカル変数を外から参照出来ないはずでした。しかし、環境外でfooに値を代入しておくことによって、環境内で代入したfooの値が環境外から参照出来るようになったのです。

実に不思議です。fooは環境外で作られたローカル変数であると同時に環境内で値を代入されたローカル変数でもあるように見えます。しかも、環境の内側から外側に通過出来ないはずのローカル変数が環境の壁を通過して外に見えているようです。まるで、量子力学のようであります。

これはRubyの難所の1つです。実は、ローカル変数に影響を及ぼす操作として「宣言」と「値の付与」の2つがあります。ローカル変数の宣言とは、ローカル変数を新しく作ることであり、その行われる位置によってローカル変数を参照出来る範囲、つまりスコープが決まります。一方、ローカル変数への値の付与とは、既に存在するローカル変数の値を変えることです。このとき、スコープは変わりません。

そして、代入式や自己代入式は、状況によってこの2つの操作「宣言」と「値の付与」のうちの任意の組み合わせの働きをします。

値の付与は(自己)代入式が評価されなければ起きませんが、宣言は(自己)代入式が字句解析されていれば、必ずしも評価される必要はありません。次の例では代入式は字句解析されますが、評価されません。

foo = "foo" if false
foo # => nil

そこで、代入式によりfooが宣言されますが、値の付与がなされません。このような場合は、暗黙的にnilが付与されます。

また、宣言は怠惰に行われます。即ち、既に同名のローカル変数が参照出来る範囲内にあるときは行われません。次のコードでは、初出のローカル変数の代入foo = "foo"では宣言と値の付与が行われますが、2度目の代入foo = "bar"では値の付与だけが行われます。

foo = "foo"
foo = "bar"

そして、この節で問題にしているのは、値の付与だけが行われる場合に当たります。③を例に振り返ってみましょう。

foo = "outer"
->{foo = "inner"}.call()
foo # => "inner"

1行目の代入式でfooが宣言され、"outer"が付与されます。ラムダリテラルの本体は外のローカル変数を参照出来る環境であり、2行目の代入式foo = "inner"からは1行目で宣言したfooを参照出来ます。そこで、ここでは宣言が行われず、この既存のfooに値"inner"が付与されます。3行目のラムダリテラルの外からは、1行目のfooが参照されますが、それが値"inner"を付与されているのです。

ちなみに、この結果を得るには、1行目の代入式では宣言さえ行われていれば良いので、それが評価されなくても同じ結果が得られます。

foo = "outer" if false
->{foo = "inner"}.call()
foo # => "inner"

コードブロックのイテレーション間でローカル変数を共有する

「繰り返し評価される文の一部を予め1度だけ評価する」節のコード例ではパラメーターを参照するメソッドの定義時にその値を即時評価して固定する方法を示しました。この節では、これとは別に、パラメーターの値がまだ付与されていない場合にのみ随時評価するという方法、即ちメモ化によって値を固定することを考えます。この節のコードは実用上最善のコードというわけではなく、ローカル変数を使いこなせるようになるために提示しています。

まず、インスタンス変数を使って次のようにメモ化を試みます。

def generate_parameter(name)
  define_method(name){@parameter ||= rand}
end

この方法だと、インスタンス変数@parametermainオブジェクトに属しているために、以下のように、同じパラメーターを参照したときばかりでなく、異なるパラメーターid1, id2の間でも値が共有されてしまいます。そのため、インスタンス変数は使えません。

generate_parameter(:id1)
generate_parameter(:id2)
id1 # => 0.26168225650891963
id1 # => 0.26168225650891963
id2  # => 0.26168225650891963
id2 # => 0.26168225650891963

そこで、ローカル変数を使ったメモ化を考えます。

def generate_parameter(name)
  define_method(name){parameter ||= rand}
end

③bのコードブロック内で宣言したローカル変数は、コードブロックの外からは参照出来ず、従って、あるイテレーションで作ったparameterを別のイテレーションから参照することも出来ません。そのため、インスタンス変数の場合とは逆に、同じパラメーターを参照したときにもメモ化が機能しません。

generate_parameter(:id1)
id1 # => 0.4965976612497356
id1 # => 0.44374228698761675

ところが、コードブロックは③bの外のローカル変数を参照出来る環境なので、コードブロックの外でローカル変数を宣言すると、それをコードブロックの各イテレーションが共有して参照することが出来ます。

def generate_parameter(name)
  parameter = nil
  define_method(name){parameter ||= rand}
end

generate_parameter(:id1)
generate_parameter(:id2)
id1 # => 0.133433485392084
id1 # => 0.133433485392084
id2 # => 0.022454218839277673
id2 # => 0.022454218839277673

上のコードで、コードブロックの外でローカル変数parameterを宣言する際にnilという値が与えられますが、これは後のコードブロック内の||=で短絡評価が起こらない値で、メモ化の仕組みを阻害しません。初回にコードブロック内でparameter ||= randが評価される際には、parameterの宣言が起きず、コードブロック外のparameterに値が付与されます。そして、2回目以降の呼び出しではこのparameterが参照され、短絡評価によりメモ化が成立します。

ただし、この節で示した仕方では、パラメーターの生成に十分に時間が掛かる場合に、複数のスレッドからパラメーターを参照すると、その同一性が保証されないことに注意して下さい。

def generate_parameter(name)
  parameter = nil
  define_method(name){parameter ||= (sleep(0.01); rand)}
end

generate_parameter(:id1)
Thread.new do
  id1 # => 0.2709929825134767
end
Thread.new do
  id1 # => 0.6785291212424616
end
Thread.list.each{|t| t.join unless t == Thread.current}

あるスレッドからid1メソッドが呼ばれてsleep(0.01); randを実行している最中に、別のスレッドからid1が呼ばれ、そのときにまだparameternilのままであり得るからです。「繰り返し評価される文の一部を予め1度だけ評価する」の節で示した仕方ではこの問題は起きません。

引数とブロックローカル変数

引数は、随時評価される環境でのみ意味を成します。なぜなら、引数はその環境にあるコードをそれを囲む文脈から切り離して様様な文脈で評価するためのものだからです。Rubyでは、随時評価される環境のうちの③, ④(ラムダリテラルやコードブロック、(特異)メソッド定義式の本体)だけが引数を受けることが出来ます。これも納得出来ることです。他の随時評価される環境⑦, ⑧(引数のデフォルト)がさらに引数を取るとなれば、堂堂巡りとなってしまうからです。

「ローカル変数の宣言と値の付与」の節で、外のローカル変数を参照出来る環境では、代入式により新しくローカル変数を宣言することなく外のローカル変数に値を付与することが出来ることを観察しました。以下、この節に関係のある③の場合を再掲します。

foo = "outer"
->{foo = "inner"}.call()
foo # => "inner"
foo = "outer"
instance_exec(){foo = "inner"}
foo # => "inner"

これに対して、受け取り部で引数に値を付与する場合は、外のローカル変数を上書きしません。次のコードでは、ラムダリテラルやコードブロックの本体が評価されるときに、その環境内では引数fooが値"inner"を持ちますが、環境の外からfooを参照すると、環境内の引数に付与された値ではなく、環境外で付与した値"outer"が返ってきます。

foo = "outer"
->foo{}.call("inner")
foo # => "outer"
foo = "outer"
instance_exec("inner"){|foo|}
foo # => "outer"

このことから、2 つの可能性が考えられます。(i) もとより引数はローカル変数とは異なるものであるので、引数fooへの値の付与がローカル変数fooに影響しない可能性と、(ii) 引数とローカル変数に区別はないが、受け取り部での引数への値の付与が、(自己)代入式とは違って、常に宣言を伴うので、外のローカル変数fooとは別の引数(= ローカル変数)fooが環境内に作られて、それに値が付与されている可能性です。

ここで、以下のように、環境内に代入式foo = "another"を加えても外のローカル変数fooは上書きされません。このことから、この代入式による上書きの対象が環境内に別にあり、それは引数fooの他になく、(ii)の可能性が正しいと分かります。

foo = "outer"
->foo{foo = "another"}.call("inner")
foo # => "outer"
foo = "outer"
instance_exec("inner"){|foo| foo = "another"}
foo # => "outer"

このように、引数の受け取り部では常に宣言が行われ、参照可能な外の同名のローカル変数があったとしても、それとは別のローカル変数が作られます。(すると、環境内からはその外のローカル変数へのアクセスは失われます。このような現象はシャドウイングと呼ばれています。)

引数の受け取り部で引数が強制的に宣言されることからの類推で、引数以外のローカル変数でも受け取り部で強制的に宣言する方法が欲しいという考えが起きるのは自然なことかも知れません。これは「ブロックローカル変数」として実現されています。環境③の引数の受け取り部に括弧やパイプの対がある場合、最後の引数の後にセミコロンを入れ、その後に変数名を(複数の場合は間にコンマを入れて)書くと、それらは強制的に宣言されたローカル変数となります。これをブロックローカル変数と言います。

foo = "outer"
->(; foo){foo = "inner"}.call()
foo # => "outer"
foo = "outer"
instance_exec(){|; foo| foo = "inner"}
foo # => "outer"

 

まとめ

Rubyは、あまり考えずによくあるコードを真似してやり過ごそうと思えば、それなりに機能するコードが書けることが多いかも知れません。今回扱った文の環境の性質は、そういったときに誤魔化されがちな事柄でしょう。でも、Rubyはこのような基礎を大切にして真面目に考えれば考えただけ、それに応じて答えてくれる、味わい深い言語でもあるのだと思います。

 

最後に

マネーフォワードでは、エンジニアを募集しています。 ご応募お待ちしています。

【採用サイト】 ■マネーフォワード採用サイトWantedly

【マネーフォワードのプロダクト】 ■お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

おトクが飛び出すクーポンサービス 『tock pop』

金融商品の比較・申し込みサイト 『Money Forward Mall』

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

本業に集中できる新しいオンライン融資サービス 『Money Forward BizAccel』


  1. この記事はRuby開発者の一人である卜部昌平氏に目を通してもらい、彼の助言によりより良くすることが出来ました。その一方で、記事に間違いがあれば、それは筆者の責に帰するものです。