Flat Leon Works

アプリやゲームを作ってます。

【Nim】引数渡しの罠

Nimには値型と参照型があるのですが、引数として渡したときの挙動が C++C# のような他のプログラミング言語とは違う場合があるようなので調べてみました。それほど詳しく調査したわけではないので注意してください。

執筆時のNimのバージョンはv0.19.0です。

この記事は Nim Advent Calendar 2018 4日目の記事として登録させてもらっています。

引数渡しの一般的な挙動

Nimの変数は値型と参照型に分かれます。プログラミング言語において一般的に、値型は代入時に中身のコピーが発生し、参照型は代入時に参照のコピーが発生するという違いがあります。そして、変数を関数(プロシージャ)への引数として渡したときには、代入と同じ処理が走るのが一般的です。これは値渡しや参照渡しと呼ばれます。C++C#の値型/参照型もこのような挙動になっています。

しかし、Nimでは値型を引数として渡したときに参照渡しになる場合があるようです。

値型が参照渡しになる例

一部の組み込み型

string型、seq型、array型は参照渡しになるようです。

試しにstring型が値型かつ参照渡しであることを確認してみます。

まず、string型が値型であることを確認します。

var str = "abc"
var str2 = str # コピーされる
str2.add( "xyz" )
echo str # 出力:abc

つぎに、引数として渡した場合に参照渡しになっていることを確認します。

var str = "abc"

proc testArgPass( v: string ) =
  echo v # 出力:abc
  str.add( "def" )
  echo v # 出力:abcdef

testArgPass( str )

上記コードのtestArgPassプロシージャ内では引数であるvに対して変更を加えていませんが、変数strの中身に変更を加えるとvの中身も変わっています。これが参照渡しになっている証拠です。

このように、一部の組み込み型は値型でも参照渡しされるようになっているようです。

オブジェクトのデータサイズが大きい場合

次のコードを見てください。Data型は値渡しで渡されています。

type
  Data = object
    value: array[3, int]

var data = Data()

proc testArgPass( v: Data ) =
  echo v.value[0] # 出力:0
  data.value[0] = 12345
  # vはdataへの変更の影響を受けていない(=値渡しになっている)
  echo v.value[0] # 出力:0

testArgPass( data )

ここでData型内のフィールドの配列のサイズを3から4に変更します。すると今度は参照渡しになります。

type
  Data = object
    value: array[4, int]

var data = Data()

proc testArgPass( v: Data ) =
  echo v.value[0] # 出力:0
  data.value[0] = 12345
  # vはdataへの変更の影響を受けている(=参照渡しになっている)
  echo v.value[0] # 出力:12345

testArgPass( data )

このように値型でもそのデータサイズが一定値以上の場合、参照渡しになるようです。

なぜ値型が参照渡しになるのか

値型なのに参照渡しになるという挙動は最適化の一種として実装されているようです。NimドキュメントのVar parameterの箇所に以下のように書かれていました。

Note: var parameters are never necessary for efficient parameter passing. Since non-var parameters cannot be modified the compiler is always free to pass arguments by reference if it considers it can speed up execution.
意訳: var仮引数を効率的な引数渡しのために使う必要はありません。非var仮引数は変更不可なので、コンパイラは高速化のためにいつでも引数を参照渡しにすることができます。

Nim Manual:Var parameters

Var parameter(Var仮引数)は、通常は変更不可な仮引数を変更可能にし、さらに参照渡しにする機能です。参照渡しは基本的に値渡しより効率がよいので、このVar仮引数をプログラムの高速化目的で使用できそうですが、それに対して「そんなことしなくてもコンパイラの最適化で参照渡しにするからわざわざVar仮引数を使う必要がないよ」とドキュメントは言っています。

値型が意図せず参照渡しになることの問題

上記のとおり、Nimのドキュメントには「仮引数は変更不可なので参照渡しに変更できる」ということが記述されています。たしかに、ほとんどの場合は問題ないと思いますが問題がないわけではありません。

問題1: 値渡しを前提に実装している場合

値渡し、つまりコピーを前提に実装している場合、参照渡しにされてしまうとプロシージャの動作が変わってしまう可能性があります。値渡しによるコピー前提で実装することはあまりありませんが、なくはないです。

type
  MyObj = object
    arr: array[2, int] # 要素数2なら値渡し、要素数3なら参照渡しになる
    value: int

var obj = MyObj(value:10)

# this.value に other.value を2回足すプロシージャ
proc addTwice( this: var MyObj, other:MyObj ) =
  this.value += other.value
  this.value += other.value

addTwice( obj, obj ) # 10 + (10 + 10) = 30 を期待

echo obj.value # 値渡しなら 30, 参照渡しなら 40になる

# 値渡し  : 10 + 10 + 10 = 30
# 参照渡し: 10 + 10 + 20 = 40

上記コードのaddTwiceプロシージャは、第一引数(this)の値に、第二引数(other)の値を2回足すプロシージャです。MyObj型は値型なので第二引数は値渡しであることを期待して、第一引数、第二引数、ともに同じobjを渡しています。値渡しなので、1回目の加算で第一引数の値が変化しても、第二引数の値はそのままであると想定しています。

ところがコンパイラにより第二引数が参照渡しにされてしまうと、1回目の加算時に第二引数の値も変化してしまい計算結果がおかしなことになります。

問題2: 参照渡し自体が持つ問題

参照渡し自体に、参照の中身が想定外に書き換わってしまうという問題があります。この問題自体は参照渡しを行うあらゆるプログラミング言語で発生する問題*1であり、発生する可能性も低いのでそこまで気にする必要もないとは思います。しかし、値渡しだと考えていると、この参照渡しの問題を完全に除外して考えてしまうので想定外のバグになってしまう可能性があります。

問題3: 参照渡しになるかどうかがコンパイラ次第

さらに問題なのが値型が参照渡しになるかどうかはコンパイラ次第だということです。今まで正常に動いていたコードがフィールドを追加しただけで、あるいはNimのバージョンを上げただけで動かなくなるなど分かりづらいバグを生む可能性もあります。

対策

1. 参照渡しになる可能性を考慮してコーディングする

意図せず参照渡しになってしまうのが問題なのであれば、参照渡しになる可能性を考慮してコーディングすれば問題への対策になります。具体的には、問題1でのサンプルコードのような「値渡しによるコピーを利用した実装」を行わないようにします。こうすることで値渡し、参照渡し、どちらになっても問題がないようにします。

2. プラグマによる値渡し、参照渡しの強制指定

実はNimには型の定義時にそのオブジェクトの引数渡し時の挙動を指定するためのプラグマ、bycopyプラグマbyrefプラグマがあります。

型の定義時にbycopyプラグマを使えば引数の渡し方が値渡しになり、byrefプラグマを使えば参照渡しになります。これらを使えば、知らぬうちに参照渡しになってしまっていたということを防ぐことができます。

利用例

type
  Data {.bycopy.} = object
    value: array[100, int] # データサイズが大きくてもbycopyプラグマにより勝手に参照渡しにならない

var data = Data()

proc testArgPass( v: Data ) =
  echo v.value[0] # 出力:0
  data.value[0] = 12345
  # vはdataへの変更の影響を受けていない(=値渡しになっている)
  echo v.value[0] # 出力:0

testArgPass( data )

ちなみに、bycopyを使っても、var仮引数にすれば参照渡しになりました。

bycopyが機能しないケース

値渡しを強制できるbycopyプラグマですが、使えない場合もあるようです。簡単に調べてみたところ以下のような場合には値渡しになりませんでした。

  • type Hoge {.bycopy.} = ref objectのように、ref型にbycopyを使っても値渡しにはならない
  • type Hoge {.bycopy.} = seq[int]のように、参照渡しされる組み込み型にbycopyを使っても値渡しにはならない

つまり、自分で定義した値型にしか使えないようです。

まとめ

  • Nimの値型はコンパイラの最適化によって勝手に参照渡しになる場合があるので注意
  • bycopy byrefプラグマを使えば勝手に参照渡しになることを防ぐことができる

やっぱり、オブジェクトのデータサイズで値渡しになったり参照渡しになったりするのはバグの元だと思うので、値型のオブジェクトにはbycopyプラグマをつけて、パフォーマンスが気になる場合だけvar仮引数を使うのが良いのではないかと個人的には思いました。

*1:*ただし、すべてのオブジェクトが変更不可な言語だとこの問題は起きないはず…