rubyで参照渡し的な挙動ができないか?

こんにちは!
先週は7ヶ月ぶりにライブに行ってきました。
LINE CUBE SHIBUYAでの講演でマスク着用、声出し厳禁でしたが、最高でした!
ドラムのキックがドスドスくる感じとか音圧、目の前にアーティストがいる感動は家では味わえないので、じわじわライブ開催が増えて欲しいです。
そして、ホール講演は楽でいいですね。
コインロッカーの確保も不要、座席指定なので開場時間前から並ぶ必要もない。
しかも、事前物販(オンライン)のみ!
引きこもり生活で体力も落ちているのでちょうどよかったです。
汗だくのライブハウスはまだまだ先なんだろうな。。。

今回のテーマ

値渡し・参照渡しの説明から入り、値渡ししか存在しないrubyで参照渡しのような挙動をさせるにはどうするかを書きます。
きっかけは、1月ほど前から、AtCoder競技プログラミングを始め、模範解答やアルゴリズムの参考コードを読むためにc++の勉強をし始めたことでした。
関数について学んでいる際に参照渡しというものが出てきたので、rubyでもできないかと思い調べてみました。
結論としては、rubyには参照渡しは存在しません。
ただ、参照渡しと同様の挙動をさせることはできます。
何を言っているのか、ちょっとよくわかりませんが、まずは参照渡しについてと、その対になる値渡しについて簡単に説明します。
個人的な要約も混じっているため、プログラミング言語内部の動きを正確に述べているわけではありませんが、概略は掴んでいると思います。

関数・メソッドへの引数の渡し方について_値渡しと参照渡し

値渡し・参照渡しと言うのは、関数(rubyではメソッド)への引数の渡し方の種類です。
内容についてはこちらの記事を参考にさせていただきました。
簡単にまとめると、以下のようになります。
なお、「参照」とは変数へのリンクだと思えば良いと思います。

  • 値渡し:引数に指定した変数の値をコピーして、関数内で使用。元の変数とは別にコピーが作成されるため、関数実行後でも引数に指定した変数の値は変化しない
  • 参照渡し:引数に指定した変数への参照を、関数内で使用。変数が持つ値を直接変更することになるため、関数実行後、引数に指定した変数の値は変化する

ポイントは、コピーを作成するかしないかです。
変数が変化しない・するというのは、上記の結果です。
言葉だけだと分かりにくいので、rubyで簡単なプログラムを書きました。

値渡しの例_その1(一般的な挙動)

def call_Taro(name)
  name = 'Taro'
  puts name
end

name = 'Jiro'
puts name

call_Taro(name)

puts name

このコードで実行していることは以下です。

  1. メソッドcall_Taroを定義。内容は、引数を'Taro'に変更して出力。
  2. 変数nameを初期値'Jiro'で定義。nameを出力。
  3. nameを引数にとり、call_Taroを呼び出し。
  4. 再度、nameを出力。

結果はどうなるでしょうか?
rubyは「値渡し」なので、メソッド内では変数のコピーに対して変更が加えられ、元の値は影響を受けず、以下のようになります。

Jiro
Taro
Jiro

値渡しの例_その2(破壊的メソッドを使用すると?)

続いて、ruby初心者がよくハマる?トラップです。
破壊的メソッド(大体「!」がついているメソッドです)であるupcase!を使用します。
upcase!は対象のstringを全て大文字に変換します。

def call_JIRO(name)
  name.upcase!
  puts name
end

name = 'Jiro'
puts name

call_JIRO(name)

puts name

この場合の結果を示します。

Jiro
JIRO
JIRO

ポイントは、最後の出力です。
なんと、'Jiro'ではなく、メソッドでの出力と同じ'JIRO'になっています。
この理由について、説明します。

破壊的メソッドを使用すると、オブジェクトの中身が変わる

rubyでは、変数それぞれにobject_idが割り当てられています。
ここでは、object_idが同じ変数は値を共有していると考えて大丈夫です。
(正確には、私も調べきれていません。。。)
rubyでの「値渡し」の際に、実はこのobject_idもコピーされて渡されています。
ここがポイントです。
上記の例_その1の場合、変数へ値を再代入するという、非破壊的メソッドを使用したため、このタイミングでobject_idが書き換わっています。
一方、例_その2の場合、いきなりupcase!という破壊的メソッドを使用したため、引数で指定した変数と同じobject_idの変数が書き換えられています。
object_idが同じ変数は値を共有しているため、値渡しであるにもかかわらず、変数の値が変化しています。
これについても、rubyプログラムで実際の挙動を確認していきます。

値渡しの例_その3(object_idを確認してみる_その1)

その1のコードを改変して、object_idの変化を見えるようにしました。

def call_Taro(name)
  puts "#{name} #{name.object_id}"
  name = 'Taro'
  puts "#{name} #{name.object_id}"
end

name = 'Jiro'
puts "#{name} #{name.object_id}"

call_Taro(name)

puts "#{name} #{name.object_id}"

結果(object_idの値は時と場合により変わります。)

Jiro 70252407078620
Jiro 70252407078620
Taro 70252407078420
Jiro 70252407078620

出力についての説明です。
1行目:メソッド実行前に初期化したnameを出力。
2行目:メソッド内で、呼び出した直後のnameを出力。この時点では、object_idが1行目と同一であることがわかる。
3行目:メソッド内で、nameに非破壊的変更を加えた後。object_idが変化している。
4行目:メソッド実行後、再度nameを出力。メソッド実行前のobject_idが呼び出されている。
このように、object_idもコピーされてメソッドに渡されていること、非破壊的メソッドを使用した場合に、object_idが変化していることがわかりました。

値渡しの例_その4(object_idを確認してみる_その2)

今度は、その2の改変版です。

def call_JIRO(name)
  puts "#{name} #{name.object_id}"
  name.upcase!
  puts "#{name} #{name.object_id}"
end

name = 'Jiro'
puts "#{name} #{name.object_id}"

call_JIRO(name)

puts "#{name} #{name.object_id}"

結果(object_idの値は時と場合により変わります。)

Jiro 70246049185640
Jiro 70246049185640
JIRO 70246049185640
JIRO 70246049185640

破壊的メソッドを使用した場合、object_idが変化していないことがわかります。(3行目)
そのため、4行目も'JIRO'に変化してしまいました。

値渡しの例_その5(破壊的メソッド後の非破壊的メソッド)

ちょっとトリッキーなパターンです。

def call_JIRO_TARO(name)
  name.upcase!
  puts name
  name = 'TARO'
  puts name
end

name = 'Jiro'
puts name

call_JIRO_TARO(name)

puts name

この場合、最後の出力は、'Jiro', 'JIRO', 'TARO'のどれになるでしょうか?
object_idの変化を考えればわかると思います。
答えは、こちら。

Jiro
JIRO
TARO
JIRO

'JIRO'でした。
メソッドの最初で、upcase!を呼び出しているので、この時点で'Jiro'から'JIRO'に変化します。
続いて、'TARO'の再代入ですが、これは非破壊的メソッドのため、object_idが変化し、'JIRO'に影響は及びません。
したがって、最終的な出力は'JIRO'になります。

一旦、まとめ

ここまでで、rubyにおいてメソッドへの引数の渡し方は値渡しであること、
object_idもコピーされて渡るため、最初に破壊的メソッドを使用すると、元の変数も変化してしまうこと、
の2点がお分かりいただけたでしょうか。
値渡しとしての挙動を担保したい場合は、最初に非破壊的メソッドを使用するように注意した方が良さそうです。

初心者がよくハマるトラップ

この後の内容とも関連しますが、配列が出てくると、ややこしいことになります。
実は、配列の要素の追加・削除や、変更は破壊的メソッドになっています。
そのため、配列の要素にメソッド内でアクセスすると、意図しない動作になることがあります。
次に示す例は、文字列の操作ですが配列の操作と同じ現象が起きています。

def call_taro(name)
  name[0] = 't'
  puts name
end

name = 'Taro'
puts name

call_taro(name)

puts name

意図としては、最後の出力は'Taro'になって欲しいです。
が、実際の結果は、'taro'になります。

Taro
taro
taro

はい、破壊的メソッドでした。
メソッドの最初に、配列自身の再代入など非破壊的メソッドを挟むことで、回避することができます。

def call_taro(name)
  name = name.dup
  name[0] = 't'
  puts name
end

name = 'Taro'
puts name

call_taro(name)

puts name

上記のコードでは、メソッドの最初にdupメソッドを使用してコピーを作成し、nameのobject_idを変更しています。
こうすることで、値渡しで予想される挙動になります。

Taro
taro
Taro

ruby再帰関数を書いてみる

ここから、後半戦です。
再帰関数を通して、rubyで参照渡し的に変数を変化させる方法を考えます。
再帰関数とは、関数の中で自身を呼び出す関数のことです。
深さ優先探索などのアルゴリズムで使用されます。
気になる方は調べてみてください。
今回は簡単な例として、下図に示す頂点1から10までの出発時刻と到着時刻を出力するプログラムを書きます。

f:id:kojiprg:20200930175335p:plain
1から10まで往復する際の、出発・到着時刻を各頂点ごとに出力する

ダメな例

まず、これまでの値渡しや参照渡しのルールを全て忘れて、それっぽいコードを書いてみます。

n = 10
departure = Array.new(n, 0)
arrival = Array.new(n, 0)
x = 0
time = 0

def count_time(departure, arrival, n, x, time)
  time += 1
  departure[x] = time

  if x < n - 1
    next_x = x + 1
    count_time(departure, arrival, n, next_x, time)
  end

  time += 1
  arrival[x] = time
end

count_time(departure, arrival, n, x, time)

n.times do |i|
  puts "#{i + 1} #{departure[i]} #{arrival[i]}"
end

各変数とメソッドの意味です。
n:頂点数
departure:出発時刻を格納する配列
arrival:到着時刻を格納する配列
x:現在の頂点番号 - 1(配列のindexが0から始まることに対応させるため)
time:現在時刻(0で初期化)
count_time:timeを更新しながら、departureに記録、次の頂点があれば次の頂点に対して再度自身を呼び出す、最後にarrivalに記録
少し、count_timeの挙動がわかりにくい方もいるかもしれません。
そういう方は、今一度図をみてください。
count_downの前半が下半分の矢印に相当します。
timeを増やして、次の頂点に進む動作です。
これを、頂点10に到着するまで繰り返すようにif文で指示しています。
一方、count_downの後半が上半分の帰りの矢印に相当します。
進んだ回数だけ、timeを更新しながら戻っていきます。
さて、結果はどうなるでしょうか。

1 1 2
2 2 3
3 3 4
4 4 5
5 5 6
6 6 7
7 7 8
8 8 9
9 9 10
10 10 11

残念な感じになっています。
原因は、timeが値渡しで渡されている点です。
本来、再帰呼び出ししているcount_downを抜けたタイミングでtimeは帰りの値に更新されていて欲しいのですが、値渡しのため、再帰前のtimeを保持しています。

n = 10
departure = Array.new(n, 0)
arrival = Array.new(n, 0)
x = 0
time = 0

def count_time(departure, arrival, n, x, time)
  time += 1
  departure[x] = time # <= ここのtimeと、

  if x < n - 1
    next_x = x + 1
    count_time(departure, arrival, n, next_x, time)
  end

  time += 1 # <= ここのtimeは、同じ値!
  arrival[x] = time
end

count_time(departure, arrival, n, x, time)

n.times do |i|
  puts "#{i + 1} #{departure[i]} #{arrival[i]}"
end

なので、timeをどうにかして更新する必要があります。
この方法をこの後、3種類示します。
その前に、もう一つ重要なポイントがあります。
それは、departureとarrivalの値が更新されていることです。
これは先ほどハマるポイントで紹介した、配列要素の変更が結果的に意図した動作を引き出しています。
つまり、今回departureもarrivalもその要素の変更という、破壊的操作しかしていないため、まるで参照渡しをしたかのように振る舞っています。
(あくまで、値渡しです。)

timeを更新する方法_その1(インスタンス変数を使用する)

方法その1はインスタンス変数の使用です。
簡単に説明すると、変数名の前に@をつけることで、スコープが拡大し、定義していないメソッド内でもその変数を使用することができるようになります。

n = 10
departure = Array.new(n, 0)
arrival = Array.new(n, 0)
x = 0
@time = 0

def count_time_v2(departure, arrival, n, x)
  @time += 1
  departure[x] = @time

  if x < n - 1
    next_x = x + 1
    count_time_v2(departure, arrival, n, next_x)
  end

  @time += 1
  arrival[x] = @time
end

count_time_v2(departure, arrival, n, x)

n.times do |i|
  puts "#{i + 1} #{departure[i]} #{arrival[i]}"
end

変更点は、timeを@timeにした点と、メソッドの引数からtimeを削除した点です。
こうすることで、各メソッドの中で同じ@timeが参照されるようになり、ちゃんと更新されていきます。
結果は、意図した通りになりました。

1 1 20
2 2 19
3 3 18
4 4 17
5 5 16
6 6 15
7 7 14
8 8 13
9 9 12
10 10 11

timeを更新する方法_その2(戻り値に指定する)

方法その2はメソッドの戻り値にtimeを指定することです。
メソッドの出口でtimeを戻り値に指定し、それを受け取ることで、timeを更新しながら帰ることができます。

n = 10
departure = Array.new(n, 0)
arrival = Array.new(n, 0)
x = 0
time = 0

def count_time_v3(departure, arrival, n, x, time)
  time += 1
  departure[x] = time

  if x < n - 1
    next_x = x + 1
    time = count_time_v3(departure, arrival, n, next_x, time)
  end

  time += 1
  arrival[x] = time
  return time
end

count_time_v3(departure, arrival, n, x, time)

n.times do |i|
  puts "#{i + 1} #{departure[i]} #{arrival[i]}"
end

count_timeの最後で、return timeとすることで、timeを戻り値に指定しています。
rubyでは自動的にメソッドの最後の行が戻り値になるため、returnを省略することが多いです。
今回は、戻り値であることを明示するため、記載しました。
再帰的にcount_timeを呼び出している箇所で、

    time = count_time_v3(departure, arrival, n, next_x, time)

とすることで、timeを更新しています。
動作がわかりにくい場合は、n=3くらいの条件で紙に書き出してみると分かるかもしれません。
結果は、インスタンス変数を使用した場合と同じになるので割愛します。

timeを更新する方法_その3(配列の要素変更が破壊的メソッドであることを悪用する)

最後の方法は、departureとarrivalが結果的に更新されていることから思いついた方法です。
明らかにinteger型であるtimeを、わざわざarray型で宣言するので、筋が悪いです。
おまけです。

n = 10
departure = Array.new(n, 0)
arrival = Array.new(n, 0)
x = 0
time = Array.new(1, 0)

def count_time_v4(departure, arrival, n, x, time)
  time[0] += 1
  departure[x] = time[0]

  if x < n - 1
    next_x = x + 1
    count_time_v4(departure, arrival, n, next_x, time)
  end

  time[0] += 1
  arrival[x] = time[0]
end

count_time_v4(departure, arrival, n, x, time)

n.times do |i|
  puts "#{i + 1} #{departure[i]} #{arrival[i]}"
end

まとめ

今回は、値渡しと参照渡しの違い、rubyでのメソッドの引数に指定した変数の挙動、参照渡しのような挙動をさせるにはどうするかという内容でした。
前半戦で様々なパターンを上げすぎて、後半戦の説明が薄い気もしていますが、メソッドで変数をいじる際に初心者がハマりそうなポイントは説明できたと思います。
(実際、ハマった結果が後半戦最初の、ダメな例です)
少々長くなってしまいましたが、最後までお付き合いいただきありがとうございました!