備忘ログ

チラシの裏的備忘録&メモ

Rcppは勝手にデータを書き換えるのか?

次の記事をよんで、そんなこともあるんだ~、そういうことに遭遇したことなかったけど。と思ったけど記事の通り再現するにはいろいろ条件がありそうなので見てみた。

qiita.com

とりあえずわかった範囲で条件を書くと

  • R上のオブジェクトをc++に引数等で変数として渡したあとその変数(R的に言うとオブジェクト)をc++内で直接操作し変数に上書きするような操作をするとR上のオブジェクトもその操作にともなって次の条件を満たすと書き換わる。
  • 上記に加えて、R上のオブジェクトをc++内に渡した変数を直接操作しその変数に上書きしたときの、その変数(R的に言うとオブジェクト)の型がR上のオブジェクトの型と一致する場合に、R上のオブジェクトも書き換わる。
  • 上記2つの条件はいずれもc++で作成した関数の出力結果は関係ない(関数の出力結果にオブジェクトが書き換わるとは限らない)。

という模様。

c++{Rcpp}の仕様が正直わからないが、この挙動はc++{Rcpp}の仕様で、Rからすると意図しない挙動に見えるが、現状の{Rcpp}的には仕様かと思う。

少し詳しく見ていく。

<追記>

記事を書いてから{Rcpp}パッケージのvignetteを読んでいたらその一つであるRcpp-FAQの5.Known Issuesに5.1. Rcpp changed the (const) object I passed by value.とあって、概ねこのことが書いてあった。

https://cran.r-project.org/web/packages/Rcpp/vignettes/Rcpp-FAQ.pdf

Rcpp objects are wrappers around the underlying R objects’ SEXP, or S-expression. The SEXP is a pointer variable that holds the location of where the R object data has been stored (R Core Team, 2023b, Section 1.1). That is to say, the SEXP does not hold the actual data of the R object but merely a reference to where the data resides. When creating a new Rcpp object for an R object to enter C++, this object will use the same SEXP that powers the original R object if the types match otherwise a new SEXP must be created to be type safe. In essence, the underlying SEXP objects are passed by reference without explicit copies being made into C++. We refer to this arrangement as a proxy model.

これがほぼすべてなんだと思う。Known Issueとなっているが、読むと仕様だと思った。

<追記終了>

元記事の再現

元コードを再掲するが、c++のコード内でRcpp::NumericVectorと書くのがややわずわらしいので、冒頭でusing namespace Rcppと宣言しておく。

#include <Rcpp.h>
using namespace Rcpp;

// [[Rcpp::export]]
NumericVector kakeru_ni(NumericVector x) {
  x = x * 2;
  return x;
}

これをexample.cppとしてRcpp::sourceCpp()をつかってRで関数として扱えるように読みこむ。

Rcpp::sourceCpp("example.cpp")

これで、R上でkakeru_ni()が関数として使用することができるようになる。

例にならってテスト用のデータフレームと、データフレームの一列を取り出し新しい変数としてつくっておく。

my_df <- tibble::tibble(
  a = as.numeric(1:10),
  b = as.numeric(1:10)
)
a_from_my_df <- my_df$a

ここで例の通り、取り出した変数にkakeru_ni()関数を使ってみると次のとおりにになる。

kakeru_ni(a_from_my_df)
##  [1]  2  4  6  8 10 12 14 16 18 20

ここでa_from_my_dfmy_dfを確認すると先の記事の通り書き換わっている。

a_from_my_df
##  [1]  2  4  6  8 10 12 14 16 18 20
my_df
## # A tibble: 10 × 2
##        a     b
##    <dbl> <dbl>
##  1     2     1
##  2     4     2
##  3     6     3
##  4     8     4
##  5    10     5
##  6    12     6
##  7    14     7
##  8    16     8
##  9    18     9
## 10    20    10

と無事(?)に記事の通り、値が書き換わっている。

R上のオブジェクトをc++に引数等で変数として渡したあとc++内でその変数(R的に言うとオブジェクト)を直接操作しオブジェクトに上書きするような操作をするとR上のオブジェクトもその操作にともなって書き換わる

R上のオブジェクトをc++に引数等で渡したあとc++内でその変数を直接操作しその変数に上書きするような操作をすると、その変数に割り当てたR上のオブジェクトもその操作にともなって書き換わる、という条件についてみていく。

上記のc++のコードでは変数xとして受けた値をx * 2と2倍した後、変数xに上書きしている。このため書き換わっている。

つまり、操作した値を受け取るオブジェクトを作っておけば回避できる。たとえば、次のkakeru_ni_kai()のように、結果を格納する変数を作っておいて、そこに操作後の値を格納するようにすると、値の書き換えは発生しなくなる。

#include <Rcpp.h>

using namespace Rcpp;

// [[Rcpp::export]]
NumericVector kakeru_ni_kai(NumericVector x){
  NumericVector result;
  result = x * 2;
  return result;
}

これをRcpp::sourceCpp()をつかってRの関数として扱えるように読み込む(省略)。そして、テストデータをもう一度先の記事の通り作成する。

my_df <- tibble::tibble(
  a = as.numeric(1:10),
  b = as.numeric(1:10)
)
a_from_my_df <- my_df$a

先の例にならい、kakeru_ni_kai()関数を使ってみると次のとおりにになる。

kakeru_ni_kai(a_from_my_df)
##  [1]  2  4  6  8 10 12 14 16 18 20

ちゃんと出力結果は2倍されている。

R上のオブジェクトを確認してみる。

a_from_my_df
##  [1]  1  2  3  4  5  6  7  8  9 10
my_df
## # A tibble: 10 × 2
##        a     b
##    <dbl> <dbl>
##  1     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

R上のオブジェクトの値は書き換わっていない。

R上のオブジェクトをc++に変数と渡したときc++内でその変数を操作しその変数に上書きしたときの、その変数(R的に言うとオブジェクト)の型がR上のオブジェクトの型と一致する場合に、R上のオブジェクトも書き換わる

R上のオブジェクトをc++に変数として渡したときにc++内でその変数を直接操作しその変数に上書きしたときの、その変数(R的に言うとオブジェクト)の型がR上のオブジェクトの型と一致する場合に、R上のオブジェクトも書き換わる、という条件についてみていく。

もとのkakeru_ni()関数の処理ではxNumericVectorとしているのでxはdouble型となる。R上で雑に確認すると、最後に返ってくる型は次の通りとなる。

is(kakeru_ni(1:10))
## [1] "numeric" "vector"

ここでテストデータをあえてint型で作ってみる。

my_df_int <- tibble::tibble(a = as.integer(1:10),
                            b = as.integer(1:10))
a_from_my_df_int <- my_df_int$a

ここで記事の例と同様にkakeru_ni()a_from_my_df_intに適用してみる。

kakeru_ni(a_from_my_df_int)
##  [1]  2  4  6  8 10 12 14 16 18 20

ここでもとのデータを確認してみる。

my_df_int
## # A tibble: 10 × 2
##        a     b
##    <int> <int>
##  1     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
a_from_my_df_int
##  [1]  1  2  3  4  5  6  7  8  9 10

書き換わっていない。c++で処理した変数の型とRのオブジェクトの型が異なると書き換わらない(書き換えられない)模様。

上記2つの条件はいずれもc++で作成した関数の出力結果は関係ない

上記2つの条件はいずれもc++で作成した関数の出力結果は関係ない、という条件についてみていく。

一番最初のc++のコードを少し変えて、c++内で変数xは2倍になり、変数xに上書きしているが、関数そのものが返す値は0となる関数をkakeru_ni_zero()として作成する。

#include <Rcpp.h>

using namespace Rcpp;
  
// [[Rcpp::export]]
double kakeru_ni_zero(NumericVector x){
 x = x * 2;
 return 0;
}

これを先の例と同様にRcpp::sourceCpp()をつかってRの関数として扱えるように読み込む(省略)。そして、テストデータをもう一度先の記事の通り作成する。

my_df <- tibble::tibble(
  a = as.numeric(1:10),
  b = as.numeric(1:10)
)
a_from_my_df <- my_df$a

ここで、a_from_my_dfkakeru_ni_zero()を使ってみる。

kakeru_ni_zero(a_from_my_df)
## [1] 0

当然関数の結果として0が返ってくる。ここでR上のデータを確認してみる。

a_from_my_df
##  [1]  2  4  6  8 10 12 14 16 18 20
my_df
## # A tibble: 10 × 2
##        a     b
##    <dbl> <dbl>
##  1     2     1
##  2     4     2
##  3     6     3
##  4     8     4
##  5    10     5
##  6    12     6
##  7    14     7
##  8    16     8
##  9    18     9
## 10    20    10

関数の出力結果の0ではなく、2倍の値で書き換わっている。このことからは、c++で作った関数の結果ではなく、c++上の変数(R的に言うとオブジェクト)の操作によって書き換わっていることがわかる。

最後に

{Rcpp}で作った関数がメモリ上のオブジェクト(変数)をコピーせずに直接アクセスしていると推察されるのは元記事のとおりだと思う。<追記>冒頭の追記のRcpp-FAQの文章を読むと参照渡しの解釈でほぼいい気がする。<追記終了>

元記事では{purrr}map系関数を使うことで解決できるとしているが、それ以外の方法として、c++のコード内で直接Rから受け取った変数(オブジェクト)を上書きしないように注意するのもお作法的に必要かと思う。そうしないとメモリ上の同じアドレスになっている変数(オブジェクト)が書き換えられることになってしまう。

Rだと、関数内でオブジェクトを操作しても殆どの場合で<<-などで無理やり親環境に代入してやらない限り、関数内での操作は関数外に影響を与えることはほぼない。たとえば、最初のc++のコードをRで書き直して、

kakeruni_r <- function(x){
  x <- x * 2
  return(x)
}

としても、ここでのxは結果として出力されるだけで、関数内から親環境のオブジェクトを書き換えることは代入などをしない限り通常はない。

my_df <- tibble::tibble(
  a = as.numeric(1:10),
  b = as.numeric(1:10)
)
a_from_my_df <- my_df$a
kakeruni_r(a_from_my_df)
##  [1]  2  4  6  8 10 12 14 16 18 20
a_from_my_df
##  [1]  1  2  3  4  5  6  7  8  9 10
my_df
## # A tibble: 10 × 2
##        a     b
##    <dbl> <dbl>
##  1     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

これがRでの常識となっている。

しかし、先の例でも見た通り、{Rcpp}などで他の言語と連携をする場合にはその言語的仕様やお作法などに注意が必要かと思う。